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