feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform
This commit is contained in:
+24
-15
@@ -145,18 +145,6 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
user.username = username
|
user.username = username
|
||||||
await db.flush()
|
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 = {}
|
panels: dict = {}
|
||||||
panel_rows = (
|
panel_rows = (
|
||||||
@@ -166,6 +154,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
for row in panel_rows:
|
for row in panel_rows:
|
||||||
panels[row.panel] = row.level
|
panels[row.panel] = row.level
|
||||||
|
|
||||||
member_project_ids: list = [
|
member_project_ids: list = [
|
||||||
row.project_id
|
row.project_id
|
||||||
for row in (
|
for row in (
|
||||||
@@ -177,6 +166,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
).scalars().all()
|
).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
|
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||||
return UserContext(
|
return UserContext(
|
||||||
user=user,
|
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:
|
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||||
"""Every project id managed by the given PM/supervisor user (oldest first)."""
|
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||||
rows = (
|
pm_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project.id)
|
select(Project.id)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(Project.pm_user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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:
|
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
claims = await _verify_token(access_token)
|
claims = await _verify_token(access_token)
|
||||||
ctx = await context_from_claims(claims, db)
|
ctx = await context_from_claims(claims, db)
|
||||||
projects = await _user_projects(db, ctx.user)
|
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("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||||
await analytics.track(
|
await analytics.track(
|
||||||
"login_success",
|
"login_success",
|
||||||
|
|||||||
@@ -169,11 +169,14 @@ async def create_quote(
|
|||||||
):
|
):
|
||||||
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||||
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||||
member = set(ctx.member_project_ids or [])
|
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||||
wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
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:
|
if not wanted:
|
||||||
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||||
if not wanted <= member:
|
if not wanted <= allowed_str:
|
||||||
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||||
quote = await quote_service.create_quote(db, payload, actor)
|
quote = await quote_service.create_quote(db, payload, actor)
|
||||||
return {"data": await serialize_quote_with_children(db, quote)}
|
return {"data": await serialize_quote_with_children(db, quote)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
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:
|
if db is not None:
|
||||||
out["panels"] = await _panels_of(db, user.id)
|
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
|
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]:
|
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"):
|
if user.role not in ("supervisor", "pm"):
|
||||||
return []
|
return []
|
||||||
|
member_subq = select(UserProjectMembership.project_id).where(
|
||||||
|
UserProjectMembership.user_id == user.id
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
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)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -246,12 +258,12 @@ async def create_user(
|
|||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
ctx: UserContext = Depends(require_role("admin")),
|
ctx: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
if payload.role == "admin" and ctx.role != "admin":
|
||||||
unassigns the user from their project."""
|
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
@@ -386,13 +398,14 @@ async def assign_user_project(
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/panels")
|
@router.put("/{user_id}/panels")
|
||||||
|
@router.patch("/{user_id}/panels")
|
||||||
async def set_user_panels(
|
async def set_user_panels(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserPanelsUpdate,
|
payload: UserPanelsUpdate,
|
||||||
_: UserContext = Depends(require_role("admin")),
|
_: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین."""
|
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
@@ -409,10 +422,11 @@ async def set_user_panels(
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/panel-projects")
|
@router.put("/{user_id}/panel-projects")
|
||||||
|
@router.patch("/{user_id}/panel-projects")
|
||||||
async def set_user_panel_projects(
|
async def set_user_panel_projects(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserPanelProjectsUpdate,
|
payload: UserPanelProjectsUpdate,
|
||||||
_: UserContext = Depends(require_role("admin")),
|
_: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
|
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
|
||||||
"short_name": "دفترچه تأمین",
|
"short_name": "مدیریت پروژه و تأمین",
|
||||||
"description": "سامانه تدارکات و مدیریت انبار پروژههای ساختمانی",
|
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
|
||||||
"dir": "rtl",
|
"dir": "rtl",
|
||||||
"lang": "fa",
|
"lang": "fa",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
|||||||
+157
-31
@@ -62,7 +62,10 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
if (user.role === "supervisor") {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
||||||
@@ -85,42 +88,137 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
|
|
||||||
if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") {
|
|
||||||
return <Navigate to="/panel/pm" replace />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنلهای تخصیصی:
|
||||||
|
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 <Navigate to="/inventory" replace />;
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/inventory") && !hasInventory) {
|
||||||
|
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// صفحهی اصلی بر اساس لایه پنلها (جایگزین نقشها):
|
// صفحهی اصلی بر اساس لایه پنلها (جایگزین نقشها):
|
||||||
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
|
|
||||||
// نقشهای میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شدهاند.
|
|
||||||
function HomeRoute() {
|
function HomeRoute() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const panels = user?.panels ?? {};
|
const panels = user?.panels ?? {};
|
||||||
if (panels.inventory) return <Navigate to="/inventory" replace />;
|
const isAdmin = user?.role === "admin";
|
||||||
if (panels.procurement === "limited") return <RequesterHome />;
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
// فقط پنل PM: مستقیم به پنل مدیریت پروژه
|
|
||||||
if (panels.pm && !panels.inventory && !panels.procurement) {
|
const hasProcurement =
|
||||||
return <Navigate to="/panel/pm" replace />;
|
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 <Navigate to="/wizard" replace />;
|
||||||
|
}
|
||||||
|
return <DashboardPage />;
|
||||||
}
|
}
|
||||||
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
|
|
||||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
// ۲. اگر پنل انبار دارد:
|
||||||
return <DashboardPage />;
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
|
||||||
|
// ۳. اگر پنل PM دارد:
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
|
||||||
|
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
|
||||||
|
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنلهای سامانه</h3>
|
||||||
|
<p className="mt-2 text-xs text-muted leading-6">
|
||||||
|
حساب کاربری شما فعال است اما در حال حاضر به هیچیک از پنلهای سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای میراث، تدارکات full
|
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای تدارکات و پنلهای تدارکات (full یا limited)
|
||||||
// و تدارکات limited (محدود به پروژههای عضویت — صفحه /requester)
|
|
||||||
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const panels = user?.panels ?? {};
|
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}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
if (panels.procurement === "full") return <>{children}</>;
|
|
||||||
if (panels.procurement === "limited") return <Navigate to="/requester" replace />;
|
const hasInventory =
|
||||||
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
||||||
@@ -134,25 +232,52 @@ function PmPanelPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای میراث pm/supervisor —
|
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای pm/supervisor
|
||||||
// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد
|
|
||||||
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const panels = user?.panels ?? {};
|
const panels = user?.panels ?? {};
|
||||||
const allowed =
|
const isAdmin = user?.role === "admin";
|
||||||
user?.role === "admin" ||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
Boolean(panels.pm) ||
|
const hasPm =
|
||||||
user?.role === "pm" ||
|
isAdmin ||
|
||||||
user?.role === "supervisor";
|
(hasPanelsConfig
|
||||||
if (!allowed) return <Navigate to="/" replace />;
|
? 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 <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
}
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
||||||
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
const isAdmin = user?.role === "admin";
|
||||||
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
|
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 <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
return <Navigate to="/panel/pm" replace />;
|
||||||
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +329,7 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/suppliers"
|
path="/suppliers"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -17,49 +17,61 @@ export function AppSwitcherMenu({
|
|||||||
}) {
|
}) {
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isSupervisor = user?.role === "supervisor";
|
const panels = user?.panels ?? {};
|
||||||
|
|
||||||
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده میشود
|
const isAdmin = user?.role === "admin";
|
||||||
if (isSupervisor) {
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
const supervisorItems: MenuItem[] = [
|
|
||||||
{
|
|
||||||
label: "انبار",
|
|
||||||
to: "/inventory",
|
|
||||||
active: activeModule === "warehouse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "مدیریت پروژه",
|
|
||||||
to: "/panel/pm",
|
|
||||||
},
|
|
||||||
{ divider: true },
|
|
||||||
{ label: "خروج از حساب", onClick: logout },
|
|
||||||
];
|
|
||||||
return <PanelMenu title="پنلها" items={supervisorItems} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
const hasProcurement =
|
||||||
const items: MenuItem[] = [
|
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: "انبار",
|
label: "انبار",
|
||||||
to: "/inventory",
|
to: "/inventory",
|
||||||
active: activeModule === "warehouse",
|
active: activeModule === "warehouse",
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
if (canSwitchToProcurement) {
|
if (hasProcurement && canSwitchToProcurement) {
|
||||||
items.push({
|
items.push({
|
||||||
label: "تدارکات",
|
label: "تدارکات",
|
||||||
to: "/",
|
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
|
||||||
active: activeModule === "procurement",
|
active: activeModule === "procurement",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
if (hasPm) {
|
||||||
label: "مدیریت پروژه",
|
items.push({
|
||||||
to: "/panel/pm",
|
label: "مدیریت پروژه",
|
||||||
});
|
to: "/panel/pm",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (canManage) {
|
if (canManageOrg) {
|
||||||
items.push({
|
items.push({
|
||||||
label: "سازماندهی",
|
label: "سازماندهی",
|
||||||
to: "/organization",
|
to: "/organization",
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
|
|||||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
async function putData<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
async function deleteData<T>(path: string): Promise<T> {
|
async function deleteData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -1663,9 +1667,9 @@ export const apiV26 = {
|
|||||||
patchData<UserRow>(`/users/${id}`, { role }),
|
patchData<UserRow>(`/users/${id}`, { role }),
|
||||||
// لایه پنلها — تخصیص پنل + سطح دسترسی و عضویت پروژهای (فقط ادمین)
|
// لایه پنلها — تخصیص پنل + سطح دسترسی و عضویت پروژهای (فقط ادمین)
|
||||||
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
|
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
|
||||||
patchData<UserRow>(`/users/${id}/panels`, { panels }),
|
putData<UserRow>(`/users/${id}/panels`, { panels }),
|
||||||
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
|
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
|
||||||
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
||||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||||
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export function LoginPage() {
|
|||||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||||
return;
|
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("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,9 +89,23 @@ export function LoginPage() {
|
|||||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
||||||
track("login_success", { role: user.role });
|
track("login_success", { role: user.role });
|
||||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||||
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقشها → دفتر پیشنهادهای خرید
|
const panels = user.panels ?? {};
|
||||||
navigate(user.role === "supervisor" ? "/panel/pm" : "/");
|
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) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -277,10 +294,21 @@ export function LoginPage() {
|
|||||||
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
|
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
|
||||||
<p className="mt-1 text-[12.5px] leading-6 text-muted">
|
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
|
||||||
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
|
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
|
||||||
|
مدیریت پروژه
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
|
||||||
|
تدارکات
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
|
||||||
|
انبارداری
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,68 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FilePlus2, Info } from "lucide-react";
|
import { FilePlus2, Info } from "lucide-react";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { useProjects } from "../../hooks";
|
import { useProjects } from "../../hooks";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای
|
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژههای تخصیصیافته
|
||||||
// پروژههای عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمیشود.
|
|
||||||
export function RequesterHome() {
|
export function RequesterHome() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const projects = useProjects();
|
const projects = useProjects();
|
||||||
const memberIds = new Set(user?.member_project_ids ?? []);
|
|
||||||
const myProjects = memberIds.size
|
// پروژههای کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژههای عمومی)
|
||||||
? projects.filter((p) => memberIds.has(p.id))
|
const myProjects = useMemo(() => {
|
||||||
: [];
|
const list: { id: string; name: string }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// ۱. پروژههای موجود در آرایه 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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="افزودن استعلام" brand />
|
<Header
|
||||||
|
title="افزودن استعلام"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
شما فقط مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
شما مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,8 +76,8 @@ export function RequesterHome() {
|
|||||||
{myProjects.map((p) => (
|
{myProjects.map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to="/wizard"
|
to={`/wizard?project=${encodeURIComponent(p.name)}`}
|
||||||
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
|
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<b className="text-[13.5px]">🏢 {p.name}</b>
|
<b className="text-[13.5px]">🏢 {p.name}</b>
|
||||||
@@ -50,7 +90,10 @@ export function RequesterHome() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link to="/wizard" className="btn-line-file no-underline">
|
<Link
|
||||||
|
to="/wizard"
|
||||||
|
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
|
||||||
|
>
|
||||||
<FilePlus2 className="h-3.5 w-3.5" />
|
<FilePlus2 className="h-3.5 w-3.5" />
|
||||||
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
|
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
|
||||||
</Link>
|
</Link>
|
||||||
@@ -60,3 +103,4 @@ export function RequesterHome() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
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 { refreshProcurementStore, useProjects } from "../../hooks";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||||
@@ -17,10 +18,12 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
supervisor: "سرپرست پروژه",
|
supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها به سرپرستان
|
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها و پنلها
|
||||||
export function OrganizationPage() {
|
export function OrganizationPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const projects = useProjects();
|
const storeProjects = useProjects();
|
||||||
|
const [directProjects, setDirectProjects] = useState<Project[]>([]);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
@@ -28,11 +31,25 @@ export function OrganizationPage() {
|
|||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
// فقط ادمین میتواند نقشها را تغییر دهد؛ مدیران میتوانند پروژهها را به سرپرستان تخصیص دهند
|
const canChangeRoles = canManage;
|
||||||
const canChangeRoles = user?.role === "admin";
|
|
||||||
|
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 () => {
|
const loadUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoadingUsers(true);
|
||||||
const list = await apiV26.users.list();
|
const list = await apiV26.users.list();
|
||||||
setUsers(list);
|
setUsers(list);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -43,8 +60,13 @@ export function OrganizationPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canManage) void loadUsers();
|
if (canManage) {
|
||||||
}, [canManage, loadUsers]);
|
void loadUsers();
|
||||||
|
void fetchProjects();
|
||||||
|
}
|
||||||
|
}, [canManage, loadUsers, fetchProjects]);
|
||||||
|
|
||||||
|
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
|
||||||
|
|
||||||
const syncUsers = async () => {
|
const syncUsers = async () => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
@@ -57,6 +79,7 @@ export function OrganizationPage() {
|
|||||||
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||||||
);
|
);
|
||||||
refreshProcurementStore();
|
refreshProcurementStore();
|
||||||
|
void fetchProjects();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
||||||
} finally {
|
} 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);
|
setBusyId(u.id);
|
||||||
const next = { ...(u.panels ?? {}) };
|
const next = { ...(u.panels ?? {}) };
|
||||||
if (level) next[panel] = level;
|
if (level) next[panel] = level;
|
||||||
@@ -157,12 +149,14 @@ export function OrganizationPage() {
|
|||||||
const panelLabel = (level: string | undefined) =>
|
const panelLabel = (level: string | undefined) =>
|
||||||
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
|
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
|
||||||
|
|
||||||
// سرپرستان تخصیصیافته به یک پروژه
|
// سرپرستان و مسئولان تخصیصیافته به یک پروژه
|
||||||
const supervisorsOf = (projectId: string) =>
|
const supervisorsOf = (projectId: string) =>
|
||||||
users.filter(
|
users.filter(
|
||||||
(u) =>
|
(u) =>
|
||||||
(u.role === "supervisor" || u.role === "pm") &&
|
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
|
||||||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
(u.projects?.some((p) => p.id === projectId) ||
|
||||||
|
u.project_id === projectId ||
|
||||||
|
u.member_project_ids?.includes(projectId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,34 +166,33 @@ export function OrganizationPage() {
|
|||||||
brand
|
brand
|
||||||
menu={<AppSwitcherMenu activeModule="organization" />}
|
menu={<AppSwitcherMenu activeModule="organization" />}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-5">
|
||||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
|
||||||
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگامسازی» اضافه میشوند. نقشها در این پنل توسط ادمین تعیین میگردند.
|
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگامسازی» اضافه میشوند. نقشهای سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین میگردد.
|
||||||
مدیران پروژه و تدارکات میتوانند پروژهها را به سرپرستان پروژه تخصیص دهند.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------- بخش ۱: جدول کاربران و نقشها ---------- */}
|
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقشها و سطوح دسترسی پنلها ---------- */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[14px] font-bold">👥 کاربران و نقشها</h3>
|
<h3 className="text-[14px] font-bold">👥 کاربران، نقشها و سطوح دسترسی پنلها</h3>
|
||||||
<p className="mt-0.5 text-[11.5px] text-muted">
|
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||||
تعیین نقش کاربران و تخصیص پروژهها به سرپرستان پروژه
|
تعیین نقش سازمانی، تخصیص پنلها (تدارکات، انبار، PM) و مشخص نمودن پروژههای عضویت کاربر
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => void syncUsers()}
|
onClick={() => void syncUsers()}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingUsers ? (
|
{loadingUsers ? (
|
||||||
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||||
) : !users.length ? (
|
) : !users.length ? (
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
|
||||||
کاربری ثبت نشده است — دکمهی «همگامسازی از Casdoor» را بزنید.
|
کاربری ثبت نشده است — دکمهی «همگامسازی از Casdoor» را بزنید.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -207,210 +200,394 @@ export function OrganizationPage() {
|
|||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">نام</th>
|
<th className="py-2.5 pe-3">نام و مشخصات</th>
|
||||||
<th className="py-2 pe-3">نام کاربری</th>
|
<th className="py-2.5 pe-3">نقش سازمانی</th>
|
||||||
<th className="py-2 pe-3">نقش</th>
|
<th className="py-2.5 pe-3">پنل تدارکات</th>
|
||||||
<th className="py-2 pe-3">پروژههای تخصیصیافته (سرپرست پروژه)</th>
|
<th className="py-2.5 pe-3">پنل انبار</th>
|
||||||
<th className="py-2">وضعیت</th>
|
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
|
||||||
|
<th className="py-2.5">پروژههای عضویت (محدوده سطح محدود)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => {
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
const isAdmin = u.role === "admin";
|
||||||
<td className="py-2.5 pe-3">
|
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
|
||||||
<b>{u.first_name} {u.last_name}</b>
|
|
||||||
</td>
|
return (
|
||||||
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
|
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
|
||||||
{u.username || "—"}
|
{/* ۱. نام و نام کاربری */}
|
||||||
</td>
|
<td className="py-2.5 pe-3">
|
||||||
<td className="py-2.5 pe-3">
|
<div className="flex flex-col">
|
||||||
{canChangeRoles ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<select
|
<span className="font-bold text-foreground">
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
{u.first_name} {u.last_name}
|
||||||
value={u.role ?? ""}
|
</span>
|
||||||
disabled={busyId === u.id}
|
<span className="font-mono text-[10px] text-muted">
|
||||||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
({u.username || "—"})
|
||||||
>
|
</span>
|
||||||
<option value="">— بدون نقش —</option>
|
</div>
|
||||||
<option value="admin">ادمین</option>
|
{busyId === u.id && (
|
||||||
<option value="procurement_manager">مدیر تدارکات</option>
|
<span className="text-[10px] text-accent animate-pulse font-medium">
|
||||||
<option value="pm">مدیر پروژه</option>
|
در حال ذخیره تغییرات…
|
||||||
<option value="supervisor">سرپرست پروژه</option>
|
</span>
|
||||||
</select>
|
)}
|
||||||
) : (
|
|
||||||
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
|
|
||||||
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 pe-3">
|
|
||||||
{u.role === "supervisor" || u.role === "pm" ? (
|
|
||||||
<div className="w-72">
|
|
||||||
<ProjectMultiSelect
|
|
||||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
|
||||||
values={assignedProjectsOf(u).map((p) => p.id)}
|
|
||||||
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
|
|
||||||
disabled={busyId === u.id}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</td>
|
||||||
<span className="text-[11px] text-muted">
|
|
||||||
{u.role === "admin"
|
{/* ۲. نقش سازمانی */}
|
||||||
? "دسترسی کامل به همه پروژهها"
|
<td className="py-2.5 pe-3">
|
||||||
: u.role === "procurement_manager"
|
{canChangeRoles ? (
|
||||||
? "دسترسی کامل مدیریتی"
|
<select
|
||||||
: "—"}
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
</span>
|
value={u.role ?? ""}
|
||||||
)}
|
disabled={busyId === u.id}
|
||||||
</td>
|
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||||
<td className="py-2.5 text-[11px] text-muted">
|
>
|
||||||
{busyId === u.id ? (
|
<option value="">— بدون نقش —</option>
|
||||||
<span className="text-accent">در حال ذخیره…</span>
|
<option value="admin">ادمین</option>
|
||||||
) : (
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
<span className="text-emerald-600">✓ ثبتشده</span>
|
<option value="pm">مدیر پروژه</option>
|
||||||
)}
|
<option value="supervisor">سرپرست پروژه</option>
|
||||||
</td>
|
</select>
|
||||||
</tr>
|
) : (
|
||||||
))}
|
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
|
||||||
|
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۳. پنل تدارکات */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||||
|
) : canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.panels?.procurement ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
|
||||||
|
>
|
||||||
|
<option value="">— بدون تخصیص —</option>
|
||||||
|
<option value="full">کامل</option>
|
||||||
|
<option value="limited">محدود (فقط استعلام)</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۴. پنل انبار */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||||
|
) : canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.panels?.inventory ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
|
||||||
|
>
|
||||||
|
<option value="">— بدون تخصیص —</option>
|
||||||
|
<option value="full">کامل (بدون تغییر موجودی)</option>
|
||||||
|
<option value="limited">محدود (با تغییر موجودی)</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۵. پنل مدیریت پروژه (PM) */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||||
|
) : canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.panels?.pm ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
|
||||||
|
>
|
||||||
|
<option value="">— بدون تخصیص —</option>
|
||||||
|
<option value="full">کامل</option>
|
||||||
|
<option value="limited">محدود</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۶. پروژههای عضویت */}
|
||||||
|
<td className="py-2.5">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژهها</span>
|
||||||
|
) : hasPanel ? (
|
||||||
|
<div className="w-72">
|
||||||
|
<ProjectMultiSelect
|
||||||
|
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||||
|
values={u.member_project_ids ?? []}
|
||||||
|
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="mt-2 text-[11px] text-muted">
|
|
||||||
سرپرستان پروژهای که پروژهای به آنها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div>
|
||||||
{projects.map((p) => {
|
<h3 className="text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||||
const sups = supervisorsOf(p.id);
|
<p className="text-[11.5px] text-muted">
|
||||||
return (
|
مشاهده وضعیت پروژهها و سرپرستان یا اعضای تخصیصیافته به هر پروژه
|
||||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
</p>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
</div>
|
||||||
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
|
{loadingProjects && (
|
||||||
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
|
<span className="text-[11px] text-muted animate-pulse">در حال بهروزرسانی پروژهها…</span>
|
||||||
{p.status || "پروژه فعال"}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{p.description && (
|
|
||||||
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
|
|
||||||
{!sups.length ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
|
|
||||||
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{sups.map((s) => (
|
|
||||||
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
|
|
||||||
<span className="font-semibold">{s.first_name} {s.last_name}</span>
|
|
||||||
<span className="text-[10px] text-muted font-mono">({s.username})</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* ---------- بخش ۳: پنلها و سطوح دسترسی (جایگزین نقشها) ---------- */}
|
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-[14px] font-bold">🔑 پنلها و سطوح دسترسی</h3>
|
|
||||||
<p className="mt-0.5 text-[11.5px] leading-5 text-muted">
|
|
||||||
به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن
|
|
||||||
استعلام برای پروژههای عضویت. انبار — کامل: همه پروژهها بدون تغییر موجودی؛ محدود: فقط پروژههای عضویت با
|
|
||||||
امکان تغییر موجودی. ادمین دستنخورده است.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
{loadingProjects && !projects.length ? (
|
||||||
<table className="w-full text-[12.5px]">
|
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
|
||||||
<thead>
|
در حال دریافت اطلاعات پروژهها…
|
||||||
<tr className="border-b border-line text-right text-muted">
|
</div>
|
||||||
<th className="py-2 pe-3">نام</th>
|
) : !projects.length ? (
|
||||||
<th className="py-2 pe-3">پنل تدارکات</th>
|
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
|
||||||
<th className="py-2 pe-3">پنل انبار</th>
|
<span>پروژهای برای نمایش یافت نشد یا در دسترس نیست.</span>
|
||||||
<th className="py-2">پروژههای عضویت (محدوده سطح محدود)</th>
|
<button
|
||||||
</tr>
|
type="button"
|
||||||
</thead>
|
onClick={() => void fetchProjects()}
|
||||||
<tbody>
|
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
|
||||||
{users.map((u) => {
|
>
|
||||||
if (u.role === "admin") {
|
🔄 تلاش مجدد برای دریافت پروژهها
|
||||||
return (
|
</button>
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
</div>
|
||||||
<td className="py-2.5 pe-3"><b>{u.first_name} {u.last_name}</b></td>
|
) : (
|
||||||
<td colSpan={3} className="py-2.5 text-[11px] text-muted">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
ادمین — دسترسی کامل به همه پنلها (دستنخورده)
|
{projects.map((p) => {
|
||||||
</td>
|
const sups = supervisorsOf(p.id);
|
||||||
</tr>
|
return (
|
||||||
);
|
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
|
||||||
}
|
<div>
|
||||||
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory);
|
<div className="mb-2 flex items-center justify-between">
|
||||||
return (
|
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
|
||||||
<td className="py-2.5 pe-3">
|
{p.status || "پروژه فعال"}
|
||||||
<b>{u.first_name} {u.last_name}</b>
|
</span>
|
||||||
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
|
</div>
|
||||||
</td>
|
{p.description && (
|
||||||
<td className="py-2.5 pe-3">
|
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||||
{canChangeRoles ? (
|
)}
|
||||||
<select
|
</div>
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
|
||||||
value={u.panels?.procurement ?? ""}
|
<div className="pt-2 border-t border-line/50">
|
||||||
disabled={busyId === u.id}
|
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
|
||||||
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
|
{!sups.length ? (
|
||||||
>
|
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
|
||||||
<option value="">— بدون تخصیص —</option>
|
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
|
||||||
<option value="full">کامل</option>
|
</div>
|
||||||
<option value="limited">محدود (فقط استعلام)</option>
|
) : (
|
||||||
</select>
|
<div className="flex flex-col gap-1">
|
||||||
) : (
|
{sups.map((s) => (
|
||||||
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
|
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
|
||||||
)}
|
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
|
||||||
</td>
|
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
|
||||||
<td className="py-2.5 pe-3">
|
</div>
|
||||||
{canChangeRoles ? (
|
))}
|
||||||
<select
|
</div>
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
)}
|
||||||
value={u.panels?.inventory ?? ""}
|
</div>
|
||||||
disabled={busyId === u.id}
|
</div>
|
||||||
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
|
);
|
||||||
>
|
})}
|
||||||
<option value="">— بدون تخصیص —</option>
|
</div>
|
||||||
<option value="full">کامل (بدون تغییر موجودی)</option>
|
)}
|
||||||
<option value="limited">محدود (با تغییر موجودی)</option>
|
</div>
|
||||||
</select>
|
|
||||||
) : (
|
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنلها ---------- */}
|
||||||
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
|
<div className="rounded-xl border border-line bg-panel p-5">
|
||||||
)}
|
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||||
</td>
|
<div>
|
||||||
<td className="py-2.5">
|
<h3 className="flex items-center gap-2 text-[15px] font-bold">
|
||||||
{hasPanel ? (
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
<div className="w-72">
|
📖
|
||||||
<ProjectMultiSelect
|
</span>
|
||||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
توضیحات پنلها و راهنمای سطوح دسترسی
|
||||||
values={u.member_project_ids ?? []}
|
</h3>
|
||||||
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
|
<p className="mt-1 text-[12px] text-muted">
|
||||||
disabled={busyId === u.id}
|
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنلهای سامانه
|
||||||
/>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
|
||||||
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
)}
|
مدل دسترسی ماژولار مبتنی بر پنل
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
);
|
|
||||||
})}
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
</tbody>
|
{/* کارت پنل تدارکات */}
|
||||||
</table>
|
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
|
||||||
|
🛒
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
|
||||||
|
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
|
||||||
|
خرید و بازرگانی
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>دسترسی کامل مدیریتی به تمامی بخشهای تدارکات</li>
|
||||||
|
<li>ثبت و ویرایش پیشفاکتورها، شرایط پرداخت و فاکتورها</li>
|
||||||
|
<li>مدیریت تأمینکنندگان، اقلام و تصمیمگیری خرید در همه پروژهها</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>فقط امکان ثبت درخواست استعلام قیمت و پیشفاکتور اولیه</li>
|
||||||
|
<li>محدود منحصراً به پروژههایی که کاربر در آنها عضویت دارد</li>
|
||||||
|
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمتها یا پروژههای غیرعضو</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کارت پنل انبار */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
|
||||||
|
📦
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
|
||||||
|
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
انبارداری و موجودی
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژهها</li>
|
||||||
|
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارشگیری کلان</li>
|
||||||
|
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>دسترسی فقط به انبارهای پروژههای عضویت کاربر</li>
|
||||||
|
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
|
||||||
|
<li>تفکیک دقیق موجودی و تراکنشهای انبار بر اساس پروژه</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کارت پنل مدیریت پروژه (PM) */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
|
||||||
|
📊
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
|
||||||
|
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
|
||||||
|
کنترل پروژه و اجرا
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>مدیریت جامع ساختار شکست کار (WBS)، زمانبندی و گانت چارت تعاملی</li>
|
||||||
|
<li>تعریف و ویرایش فعالیتها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
|
||||||
|
<li>پایش شاخصهای پیشرفت زمانی و مالی (EVM) و گزارشهای تحلیلی</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>مشاهده فعالیتها و تسکهای مربوط به پروژههای عضویت کاربر</li>
|
||||||
|
<li>بهروزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسکها</li>
|
||||||
|
<li>تکمیل چکلیستهای کنترل کیفیت (QC) و ثبت گزارشهای اجرایی روزانه</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
|
||||||
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||||
|
<span className="text-[16px] leading-none">👑</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
|
||||||
|
<p className="mt-0.5 text-muted leading-relaxed">
|
||||||
|
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنلها و پروژهها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنلها ندارد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||||
|
<span className="text-[16px] leading-none">🎯</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-foreground">کارکرد «پروژههای عضویت»:</span>
|
||||||
|
<p className="mt-0.5 text-muted leading-relaxed">
|
||||||
|
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه دادهها و اختیارات وی دقیقاً به پروژههای انتخابی در ستون «پروژههای عضویت» محدود خواهد شد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -213,7 +213,11 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!projects.length && (
|
{!projects.length && (
|
||||||
<div className="dd-empty">هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
|
<div className="dd-empty">
|
||||||
|
{canAddProject
|
||||||
|
? "هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید."
|
||||||
|
: "هنوز پروژهای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* افزودن پروژه بهصورت درجا — زیر لیست پروژهها (سطح limited: ممنوع) */}
|
{/* افزودن پروژه بهصورت درجا — زیر لیست پروژهها (سطح limited: ممنوع) */}
|
||||||
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
|
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { normalize } from "../../lib/normalize";
|
|||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { matchMaterial } from "../../lib/quoteUtils";
|
import { matchMaterial } from "../../lib/quoteUtils";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { Stepper } from "./Stepper";
|
import { Stepper } from "./Stepper";
|
||||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||||
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
||||||
@@ -54,13 +55,19 @@ export function WizardPage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isLimitedProcurement =
|
const isLimitedProcurement =
|
||||||
user?.panels?.procurement === "limited" && user?.role !== "admin";
|
user?.panels?.procurement === "limited" && user?.role !== "admin";
|
||||||
const memberProjectNames = useMemo(
|
const memberProjectNames = useMemo(() => {
|
||||||
() =>
|
const allowedIds = new Set([
|
||||||
projects
|
...(user?.member_project_ids ?? []),
|
||||||
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
|
...(user?.projects ?? []).map((p) => p.id),
|
||||||
.map((p) => p.name),
|
...(user?.project_id ? [user.project_id] : []),
|
||||||
[projects, user],
|
]);
|
||||||
);
|
const matched = projects
|
||||||
|
.filter((p) => allowedIds.has(p.id))
|
||||||
|
.map((p) => p.name);
|
||||||
|
const directNames = (user?.projects ?? []).map((p) => p.name);
|
||||||
|
if (user?.project_name) directNames.push(user.project_name);
|
||||||
|
return [...new Set([...matched, ...directNames])];
|
||||||
|
}, [projects, user]);
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
const createQuote = useCreateQuote();
|
const createQuote = useCreateQuote();
|
||||||
const addSupplier = useAddSupplier();
|
const addSupplier = useAddSupplier();
|
||||||
@@ -95,22 +102,37 @@ export function WizardPage() {
|
|||||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
// ---------- prefill از لینکها (?topic=&supplier=&project=) ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = params.get("topic");
|
const t = params.get("topic");
|
||||||
if (!t) return;
|
if (t) {
|
||||||
const topic = topicByName(t, topics);
|
const topic = topicByName(t, topics);
|
||||||
if (!topic) return;
|
if (topic) {
|
||||||
setSelectedTopic(topic.name);
|
setSelectedTopic(topic.name);
|
||||||
const sid = params.get("supplier");
|
const sid = params.get("supplier");
|
||||||
if (sid) {
|
if (sid) {
|
||||||
const sup = suppliers.find((s) => s.id === sid);
|
const sup = suppliers.find((s) => s.id === sid);
|
||||||
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
||||||
setSupplierName(sup.name);
|
setSupplierName(sup.name);
|
||||||
setSupplierInvalid(false);
|
setSupplierInvalid(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [params, suppliers, topics]);
|
|
||||||
|
const proj = params.get("project");
|
||||||
|
if (proj) {
|
||||||
|
setAssignment((cur) => {
|
||||||
|
const next = { ...cur };
|
||||||
|
for (const it of items) {
|
||||||
|
if (!next[it.tempId]?.length) {
|
||||||
|
next[it.tempId] = [proj];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [params, suppliers, topics, items]);
|
||||||
|
|
||||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||||
const completedSteps = useMemo(() => {
|
const completedSteps = useMemo(() => {
|
||||||
@@ -143,11 +165,10 @@ export function WizardPage() {
|
|||||||
|
|
||||||
// ---------- پروژهها ----------
|
// ---------- پروژهها ----------
|
||||||
const projectNames = useMemo(() => {
|
const projectNames = useMemo(() => {
|
||||||
const names = new Set<string>(
|
if (isLimitedProcurement) {
|
||||||
isLimitedProcurement
|
return memberProjectNames;
|
||||||
? memberProjectNames
|
}
|
||||||
: projects.map((p) => p.name),
|
const names = new Set<string>(projects.map((p) => p.name));
|
||||||
);
|
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
if (q.project) names.add(q.project);
|
if (q.project) names.add(q.project);
|
||||||
(q.projects || []).forEach((n) => names.add(n));
|
(q.projects || []).forEach((n) => names.add(n));
|
||||||
@@ -155,6 +176,24 @@ export function WizardPage() {
|
|||||||
return [...names];
|
return [...names];
|
||||||
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
|
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
|
||||||
|
|
||||||
|
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص مییابند
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLimitedProcurement && memberProjectNames.length === 1) {
|
||||||
|
const sole = memberProjectNames[0];
|
||||||
|
setAssignment((cur) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...cur };
|
||||||
|
for (const it of items) {
|
||||||
|
if (!next[it.tempId]?.length) {
|
||||||
|
next[it.tempId] = [sole];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : cur;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isLimitedProcurement, memberProjectNames, items]);
|
||||||
|
|
||||||
const assign = (tempId: string, proj: string) => {
|
const assign = (tempId: string, proj: string) => {
|
||||||
setAssignment((a) => {
|
setAssignment((a) => {
|
||||||
const cur = a[tempId] || [];
|
const cur = a[tempId] || [];
|
||||||
@@ -431,8 +470,9 @@ export function WizardPage() {
|
|||||||
<Header
|
<Header
|
||||||
title="افزودن پیشنهاد جدید"
|
title="افزودن پیشنهاد جدید"
|
||||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
backTo="/"
|
backTo={isLimitedProcurement ? "/requester" : "/"}
|
||||||
backLabel="بازگشت به دفتر"
|
backLabel={isLimitedProcurement ? "بازگشت به استعلامها" : "بازگشت به دفتر"}
|
||||||
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
/>
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* نوار مراحل استپر */}
|
{/* نوار مراحل استپر */}
|
||||||
|
|||||||
Reference in New Issue
Block a user