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:
2026-10-02 03:47:33 +03:30
parent e9fef5a104
commit df3ff4ea14
14 changed files with 846 additions and 384 deletions
+24 -15
View File
@@ -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:
+2 -1
View File
@@ -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",
+6 -3
View File
@@ -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)}
+25 -11
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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={
+42 -30
View File
@@ -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",
+6 -2
View File
@@ -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 (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
+35 -7
View File
@@ -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>
+55 -11
View File
@@ -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>
+5 -1
View File
@@ -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}>
+66 -26
View File
@@ -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>
{/* نوار مراحل استپر */} {/* نوار مراحل استپر */}