2 Commits
22 changed files with 1345 additions and 776 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": "/",
+155 -29
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 />;
} }
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />; return <DashboardPage />;
} }
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full // ۲. اگر پنل انبار دارد:
// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester) 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)
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={
+38 -26
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[] = [
{ 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: "انبار", label: "انبار",
to: "/inventory", to: "/inventory",
active: activeModule === "warehouse", 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"; if (hasProcurement && canSwitchToProcurement) {
const items: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
];
if (canSwitchToProcurement) {
items.push({ items.push({
label: "تدارکات", label: "تدارکات",
to: "/", to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement", active: activeModule === "procurement",
}); });
} }
if (hasPm) {
items.push({ items.push({
label: "مدیریت پروژه", label: "مدیریت پروژه",
to: "/panel/pm", 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 (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
+13 -3
View File
@@ -62,7 +62,7 @@ export function formatMaterialTitle(
const normName = normalize(rawName); const normName = normalize(rawName);
if (normName.startsWith(normBrand)) { if (normName.startsWith(normBrand)) {
const rest = rawName.slice(rawBrand.length).trim(); const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim(); rawName = rest.replace(/^[.\s·-]+/, "").trim();
} }
} }
@@ -291,13 +291,22 @@ export function filterQuotes(
const STATUS_BY_LABEL: Record<string, string> = { const STATUS_BY_LABEL: Record<string, string> = {
"پیشنهاد شده": "offered", "پیشنهاد شده": "offered",
"در انتظار تایید": "offered",
"در انتظار تأیید": "offered",
"پذیرفته شده": "accepted", "پذیرفته شده": "accepted",
"در حال خرید": "accepted",
"رسیده": "arrived", "رسیده": "arrived",
"تکمیل شده": "arrived",
"باز": "offered", "باز": "offered",
"پذیرفته": "accepted", "پذیرفته": "accepted",
"تکمیل": "arrived",
"offered": "offered", "offered": "offered",
"accepted": "accepted", "accepted": "accepted",
"arrived": "arrived", "arrived": "arrived",
"rejected": "rejected",
"رد شده": "rejected",
"expired": "expired",
"منقضی شده": "expired",
}; };
const stKey = STATUS_BY_LABEL[stVal] || stVal; const stKey = STATUS_BY_LABEL[stVal] || stVal;
@@ -309,9 +318,10 @@ export function filterQuotes(
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
if (q) { if (q) {
const projectsText = `${(quote.projects || []).join(" ")} ${quote.project || ""}`;
const hay = const hay =
`${supObj.name} ${quote.project || ""} ` + `${quote.id} PR-${quote.id} ${supObj.name} ${projectsText} ` +
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); matInfo.map((m) => `${m.name} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false; if (!hay.toLowerCase().includes(q)) return false;
} }
return true; return true;
+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>
@@ -235,9 +235,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
{/* هدر ثابت و هم‌تراز */} {/* هدر ثابت و هم‌تراز */}
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right"> <div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right"> <DialogHeader className="p-0 text-right">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-right text-lg font-bold text-slate-800"> <DialogTitle className="text-right text-lg font-bold text-slate-800">
پذیرش پیشنهاد — {sup.name} پذیرش پیشنهاد — {sup.name}
</DialogTitle> </DialogTitle>
<span className="font-mono text-xs font-bold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-lg border border-line shrink-0 select-all">
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
</span>
</div>
</DialogHeader> </DialogHeader>
<p className="text-[13px] text-muted mt-1 text-right"> <p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید. شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
@@ -132,6 +132,7 @@ export function DashboardPage() {
/> />
<PageLayout className="flex flex-col gap-3 px-3 md:px-6"> <PageLayout className="flex flex-col gap-3 px-3 md:px-6">
<div className="hidden md:block">
<StatsBar <StatsBar
total={stats.total} total={stats.total}
offered={stats.offered} offered={stats.offered}
@@ -140,12 +141,14 @@ export function DashboardPage() {
activeFilter={activeStatusFilter} activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange} onFilterChange={handleStatusFilterChange}
/> />
</div>
<FilterToolbar <FilterToolbar
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
suppliers={supplierOptions} suppliers={supplierOptions}
sortDir={sortDir} sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))} onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
stats={stats}
/> />
<LedgerTable <LedgerTable
store={store} store={store}
@@ -462,7 +462,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl"> <DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
{/* هدر */} {/* هدر */}
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right"> <DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle> <DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
{q && (
<span className="font-mono text-xs font-bold text-gray-700 bg-gray-100 px-2.5 py-1 rounded-lg border border-gray-200 shrink-0 select-all">
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
</span>
)}
</div>
<div className="text-[12px] text-gray-500 truncate mt-0.5"> <div className="text-[12px] text-gray-500 truncate mt-0.5">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"} {supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div> </div>
+138 -97
View File
@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; import { Search, SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
export interface Filters { export interface Filters {
supplier: string; supplier: string;
@@ -8,29 +10,33 @@ export interface Filters {
search: string; search: string;
} }
// گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه // گزینه‌های استاندارد وضعیت برای دراپ‌داون فیلترهای پیشرفته
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ id: "offered", label: "پیشنهاد باز" }, { id: "offered", label: "در انتظار تایید" },
{ id: "accepted", label: "پذیرفته‌شده" }, { id: "accepted", label: "در حال خرید" },
{ id: "arrived", label: "تحویل‌شده" }, { id: "arrived", label: "تکمیل شده" },
]; ];
const STATUS_MAP: Record<string, string> = { const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز", offered: "در انتظار تایید",
accepted: "پذیرفته‌شده", accepted: "در حال خرید",
arrived: "تحویل‌شده", arrived: "تکمیل شده",
}; };
const STATUS_TO_KEY: Record<string, string> = { const STATUS_TO_KEY: Record<string, string> = {
"در انتظار تایید": "offered",
"در انتظار تأیید": "offered",
"پیشنهاد باز": "offered", "پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered", "پیشنهاد شده": "offered",
"در حال خرید": "accepted",
"پذیرفته‌شده": "accepted",
"پذیرفته شده": "accepted", "پذیرفته شده": "accepted",
"تکمیل شده": "arrived",
"تحویل‌شده": "arrived",
"رسیده": "arrived", "رسیده": "arrived",
"باز": "offered", "باز": "offered",
"پذیرفته": "accepted", "پذیرفته": "accepted",
"تحویل": "arrived", "تکمیل": "arrived",
offered: "offered", offered: "offered",
accepted: "accepted", accepted: "accepted",
arrived: "arrived", arrived: "arrived",
@@ -42,6 +48,7 @@ export interface FilterToolbarProps {
suppliers: { id: string; name: string }[]; suppliers: { id: string; name: string }[];
sortDir: "desc" | "asc"; sortDir: "desc" | "asc";
onToggleSort: () => void; onToggleSort: () => void;
stats?: { total: number; offered: number; accepted: number; arrived: number };
} }
export function FilterToolbar({ export function FilterToolbar({
@@ -50,97 +57,179 @@ export function FilterToolbar({
suppliers, suppliers,
sortDir, sortDir,
onToggleSort, onToggleSort,
stats,
}: FilterToolbarProps) { }: FilterToolbarProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch }); const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
// شمارش تعداد فیلترهای فعال به جز سرچ // شمارش تعداد فیلترهای فعال به جز سرچ و تب وضعیت
const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length; const activeFilterCount = [filters.supplier].filter(Boolean).length;
const clearFilters = () => { const clearSupplierFilter = () => {
onChange({ ...filters, supplier: "", status: "" }); set({ supplier: "" });
}; };
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس const clearAllFilters = () => {
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status; onChange({ supplier: "", status: "", search: "" });
};
const normalizedStatus = STATUS_TO_KEY[filters.status] ?? filters.status ?? "";
const statusDisplayValue = STATUS_MAP[normalizedStatus] || filters.status;
const handleStatusChange = (val: string) => { const handleStatusChange = (val: string) => {
const key = STATUS_TO_KEY[val] || val; const key = STATUS_TO_KEY[val] || val;
set({ status: key }); set({ status: key });
}; };
const STATUS_TABS: {
key: string;
label: string;
countKey?: keyof NonNullable<FilterToolbarProps["stats"]>;
}[] = [
{ key: "", label: "همه", countKey: "total" },
{ key: "offered", label: "در انتظار تایید", countKey: "offered" },
{ key: "accepted", label: "در حال خرید", countKey: "accepted" },
{ key: "arrived", label: "تکمیل شده", countKey: "arrived" },
];
return ( return (
<> <div className="flex flex-col gap-2.5">
{/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */} {/* ===== ردیف جستجو و کنترل‌های فیلتر (تک‌ردیف افقی فشرده) ===== */}
<div className="flex md:hidden items-center gap-2"> <div className="flex items-center gap-2">
{/* اینپوت جستجوی متنی */} {/* فیلد جستجوی متنی پروژه / کالا / تأمین‌کننده */}
<div className="relative flex-1"> <div className="relative flex-1">
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted">
<Search className="h-4 w-4" />
</div>
<input <input
type="search" type="search"
className="h-10 w-full rounded-xl border border-line bg-panel px-3 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="h-10 w-full rounded-xl border border-line bg-panel pr-9 pl-8 text-xs sm:text-sm text-ink shadow-2xs placeholder:text-muted focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/25 transition-all"
placeholder="جستجو در نام، برند، پروژه..." placeholder="جستجو در پروژه، کالا، تأمین‌کننده..."
value={filters.search} value={filters.search}
onChange={(e) => set({ search: e.target.value })} onChange={(e) => set({ search: e.target.value })}
/> />
{filters.search && (
<button
type="button"
onClick={() => set({ search: "" })}
className="absolute inset-y-0 left-0 flex items-center pl-2.5 text-muted hover:text-ink cursor-pointer"
aria-label="پاک کردن جستجو"
>
<X className="h-4 w-4" />
</button>
)}
</div> </div>
{/* دکمه فیلترها با بج تعداد انتخاب‌شده */} {/* دکمه باز کردن فیلترهای پیشرفته (تأمین‌کننده و ...) */}
<button <button
type="button" type="button"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={`relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-sm transition-colors ${ className={cn(
"relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-2xs transition-all shrink-0 cursor-pointer",
activeFilterCount > 0 activeFilterCount > 0
? "border-accent bg-accent-bg text-accent" ? "border-accent bg-accent-bg text-accent-dark ring-1 ring-accent/30"
: "border-line bg-panel text-ink hover:bg-slate-bg" : "border-line bg-panel text-ink hover:bg-slate-50 dark:hover:bg-zinc-800",
}`} )}
title="فیلترها" title="فیلترهای پیشرفته"
> >
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
<span>فیلتر</span> <span className="hidden xs:inline">فیلترها</span>
{activeFilterCount > 0 && ( {activeFilterCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-[11px] font-bold text-white"> <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 text-[10.5px] font-bold text-white">
{activeFilterCount} {activeFilterCount}
</span> </span>
)} )}
</button> </button>
{/* دکمه تغییر جهت سورت */} {/* دکمه تغییر جهت مرتب‌سازی */}
<button <button
type="button" type="button"
onClick={onToggleSort} onClick={onToggleSort}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg" className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-2xs hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
title={sortDir === "desc" ? "جدیدترین اول" : "قدیمی‌ترین اول"} title={sortDir === "desc" ? "مرتب‌سازی: جدیدترین اول" : "مرتب‌سازی: قدیمی‌ترین اول"}
aria-label={sortDir === "desc" ? "مرتب‌سازی جدیدترین" : "مرتب‌سازی قدیمی‌ترین"}
> >
<ArrowUpDown className="h-4 w-4 text-accent" /> <ArrowUpDown className={cn("h-4 w-4 text-accent transition-transform", sortDir === "asc" && "rotate-180")} />
</button> </button>
</div> </div>
{/* باتم‌شیت کشویی فیلترها برای موبایل */} {/* ===== تب‌های اسکرول‌افقی چیپ وضعیت (Filter Chips) ===== */}
<div className="flex items-center gap-1.5 overflow-x-auto py-0.5 no-scrollbar -mx-1 px-1 select-none">
{STATUS_TABS.map((tab) => {
const isActive = (!normalizedStatus && !tab.key) || normalizedStatus === tab.key;
const count = stats && tab.countKey ? stats[tab.countKey] : null;
return (
<button
key={tab.label}
type="button"
onClick={() => handleStatusChange(tab.key)}
className={cn(
"group inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition-all cursor-pointer shadow-2xs active:scale-[0.98]",
isActive
? "bg-accent text-white shadow-sm ring-2 ring-accent/25"
: "border border-line bg-panel text-ink/80 hover:bg-slate-50 dark:hover:bg-zinc-800 hover:text-ink",
)}
>
<span>{tab.label}</span>
{count != null && (
<span
className={cn(
"inline-flex items-center justify-center rounded-md px-1.5 py-0.5 text-[10.5px] tabular-nums font-bold leading-none transition-colors",
isActive
? "bg-white/20 text-white"
: "bg-slate-100 dark:bg-zinc-800 text-muted group-hover:text-ink",
)}
>
{fmtNum(count)}
</span>
)}
</button>
);
})}
{/* برچسب چیپ تأمین‌کننده در صورت فعال بودن با امکان حذف سریع */}
{filters.supplier && (
<span className="inline-flex h-9 shrink-0 items-center gap-1 rounded-xl border border-accent/40 bg-accent-bg px-2.5 text-xs font-semibold text-accent-dark">
<span className="max-w-[120px] truncate">تأمین‌کننده: {filters.supplier}</span>
<button
type="button"
onClick={clearSupplierFilter}
className="rounded-full p-0.5 hover:bg-accent/20 cursor-pointer"
title="حذف فیلتر تأمین‌کننده"
>
<X className="h-3 w-3" />
</button>
</span>
)}
</div>
{/* ===== باتم‌شیت کشویی فیلترهای تکمیلی (موبایل و تبلت) ===== */}
{isOpen && ( {isOpen && (
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs md:hidden"> <div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs">
<div className="fixed inset-0" onClick={() => setIsOpen(false)} aria-hidden="true" /> <div className="fixed inset-0" onClick={() => setIsOpen(false)} aria-hidden="true" />
<div className="relative z-10 max-h-[85vh] w-full overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 shadow-2xl"> <div className="relative z-10 max-h-[85vh] w-full max-w-lg mx-auto overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 sm:p-5 shadow-2xl">
{/* هدر کشو */} {/* هدر کشو */}
<div className="mb-4 flex items-center justify-between border-b border-line pb-3"> <div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-accent" /> <SlidersHorizontal className="h-4 w-4 text-accent" />
<span className="text-sm font-bold text-ink">فیلترهای پیشرفته</span> <span className="text-sm font-bold text-ink">فیلترهای تکمیلی</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{activeFilterCount > 0 && ( {(filters.supplier || filters.status || filters.search) && (
<button <button
type="button" type="button"
onClick={clearFilters} onClick={clearAllFilters}
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline" className="inline-flex items-center gap-1 text-xs text-rose-600 dark:text-rose-400 font-semibold hover:underline cursor-pointer"
> >
<RotateCcw className="h-3 w-3" /> <RotateCcw className="h-3.5 w-3.5" />
پاک کردن پاک کردن همه
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="rounded-lg p-1 text-muted hover:bg-slate-bg hover:text-ink" className="rounded-lg p-1 text-muted hover:bg-slate-50 dark:hover:bg-zinc-800 hover:text-ink cursor-pointer"
aria-label="بستن" aria-label="بستن"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
@@ -149,9 +238,9 @@ export function FilterToolbar({
</div> </div>
{/* دراپ‌داون‌ها داخل کشو */} {/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5"> <div className="flex flex-col gap-4">
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label> <label className="mb-1.5 block text-xs font-bold text-ink">تأمین‌کننده</label>
<Combobox <Combobox
value={filters.supplier} value={filters.supplier}
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })} onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
@@ -163,7 +252,7 @@ export function FilterToolbar({
</div> </div>
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label> <label className="mb-1.5 block text-xs font-bold text-ink">وضعیت</label>
<Combobox <Combobox
value={statusDisplayValue} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
@@ -179,61 +268,13 @@ export function FilterToolbar({
<button <button
type="button" type="button"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="mt-5 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99]" className="mt-6 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99] transition-transform cursor-pointer"
> >
مشاهده نتایج مشاهده نتایج
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
<div className="hidden md:flex flex-wrap items-end gap-3">
<div className="field min-w-[170px] flex-1">
<label>تأمین‌کننده</label>
<Combobox
value={filters.supplier}
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
items={suppliers}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ supplier: v })}
/>
</div> </div>
<div className="field min-w-[150px] flex-1">
<label>وضعیت</label>
<Combobox
value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_OPTIONS}
allowAdd={false}
placeholder="همه"
onInputChange={handleStatusChange}
/>
</div>
<div className="field min-w-[200px] flex-1">
<label>جستجو</label>
<input
type="search"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="جستجو در نام/برند/پروژه..."
value={filters.search}
onChange={(e) => set({ search: e.target.value })}
/>
</div>
<div className="field min-w-[140px]">
<label>ترتیب</label>
<button
type="button"
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm hover:bg-slate-bg"
onClick={onToggleSort}
title={sortDir === "desc" ? "تغییر به قدیمی‌ترین اول" : "تغییر به جدیدترین اول"}
>
<span aria-hidden className="text-accent">⇅</span>
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمی‌ترین اول"}</span>
</button>
</div>
</div>
</>
); );
} }
+250 -288
View File
@@ -1,19 +1,29 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react"; import {
Building2,
Boxes,
Calendar,
Hash,
CreditCard,
ChevronDown,
Pencil,
Package,
TriangleAlert,
AlarmClock,
} from "lucide-react";
import type { Quote, Store } from "../../lib/types"; import type { Quote, Store } from "../../lib/types";
import { import {
isExpired, isExpired,
materialById, materialById,
materialLabel, formatMaterialTitle,
projectCell, projectCell,
quoteTotal, quoteTotal,
supplierById, supplierById,
termsheetSummary, termsheetSummary,
unitWarning,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { STATUS_LABEL } from "../../lib/constants"; import { topicByName } from "../../lib/topics";
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters"; import { fmtDate, fmtNum } from "../../lib/formatters";
import { Badge } from "../../components/ui/badge"; import { groupCommas } from "../../lib/numutil";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { QuoteDetail } from "./QuoteDetail"; import { QuoteDetail } from "./QuoteDetail";
@@ -27,318 +37,270 @@ export interface QuoteCardProps {
canWrite?: boolean; canWrite?: boolean;
} }
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { export function QuoteCard({
return ( store,
<div className={cn("min-w-0", className)}> q,
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div> expanded,
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div> onToggleExpand,
</div> onAccept,
); onEdit,
} canWrite = true,
}: QuoteCardProps) {
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id); const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q); const expired = isExpired(q);
const total = quoteTotal(q); const total = quoteTotal(q);
const pc = priceCell(total);
const ts = termsheetSummary(store, q); const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
// ستون / دکمه پذیرش const activeItems = (q.line_items || []).filter((li) => !li.rejected);
let acceptedCell: React.ReactNode; const primaryLineItem = activeItems[0];
if (q.status === "offered") { const primaryMaterial = primaryLineItem
acceptedCell = canWrite ? ( ? materialById(store.materials, primaryLineItem.material_id)
: null;
// عنوان اصلی کالا / متریال
let itemTitle = "پیشنهاد بدون قلم";
if (activeItems.length === 1 && primaryMaterial) {
itemTitle = formatMaterialTitle(primaryMaterial);
} else if (activeItems.length > 1 && primaryMaterial) {
itemTitle = `${formatMaterialTitle(primaryMaterial)} (+${fmtNum(activeItems.length - 1)} قلم دیگر)`;
}
// آیکون دسته‌بندی
const topicName = primaryMaterial?.topic || sup.topic;
const topic = topicByName(topicName, store.topics);
const categoryIcon = topic?.icon;
// محاسبه مجموع مقدار و واحدها
let quantityDisplay = "—";
if (activeItems.length === 1 && primaryLineItem) {
quantityDisplay =
primaryLineItem.quantity != null
? `${fmtNum(primaryLineItem.quantity)} ${primaryLineItem.unit || ""}`
: "—";
} else if (activeItems.length > 1) {
const totalQty = activeItems.reduce((acc, li) => acc + (li.quantity || 0), 0);
const uniqueUnits = [...new Set(activeItems.map((li) => li.unit).filter(Boolean))].join("، ");
quantityDisplay = `${fmtNum(totalQty)} ${uniqueUnits || "واحد"} (${fmtNum(activeItems.length)} قلم)`;
}
// نام پروژه
const projectName =
q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q);
// بج وضعیت با کدگذاری رنگی معنایی (Semantic Color Coding)
let statusBadge: React.ReactNode;
if (expired) {
statusBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 dark:bg-amber-950/40 px-2 py-0.5 text-[11px] font-bold text-amber-700 dark:text-amber-400">
<AlarmClock className="h-3 w-3 shrink-0" />
منقضی شده
</span>
);
} else if (q.status === "arrived") {
statusBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 dark:text-emerald-300">
✓ تکمیل شده
</span>
);
} else if (q.status === "accepted") {
const fulfilledItems = activeItems.filter(
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
).length;
const anyReceived = activeItems.some((li) => (li.received_qty || 0) > 0);
statusBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40 px-2.5 py-0.5 text-[11px] font-bold text-blue-700 dark:text-blue-300">
{anyReceived ? `در حال خرید (${fmtNum(fulfilledItems)}/${fmtNum(activeItems.length)})` : "در حال خرید"}
</span>
);
} else if (q.status === "rejected") {
statusBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-rose-200 dark:border-rose-800 bg-rose-50 dark:bg-rose-950/40 px-2.5 py-0.5 text-[11px] font-bold text-rose-700 dark:text-rose-300">
رد شده
</span>
);
} else {
// offered / پیش‌فرض در انتظار تایید
statusBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 dark:text-amber-300">
در انتظار تایید
</span>
);
}
return (
<div
className={cn(
"overflow-hidden rounded-2xl border bg-panel transition-all shadow-2xs",
expired ? "border-amber-300/80 bg-amber-50/10" : "border-line hover:border-accent/40",
expanded && "border-accent/60 ring-2 ring-accent/15 shadow-sm",
)}
>
{/* ===== سربرگ کارت (Header: راست عنوان و آیکون / چپ وضعیت) ===== */}
<div className="flex items-start justify-between gap-3 border-b border-line/70 bg-slate-50/50 dark:bg-zinc-800/30 p-3.5 sm:p-4">
{/* بخش راست (RTL): آیکون دسته‌بندی + نام کالا + تأمین‌کننده */}
<div className="flex items-start gap-2.5 min-w-0 flex-1">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent-bg text-accent-dark overflow-hidden shadow-2xs border border-accent/20">
{categoryIcon ? (
<img src={categoryIcon} alt="" className="h-6 w-6 object-contain" loading="lazy" />
) : (
<Package className="h-5 w-5 text-accent" />
)}
</div>
<div className="min-w-0 flex-1">
<h3
className="truncate text-sm sm:text-[15px] font-bold text-ink leading-tight"
title={itemTitle}
>
{itemTitle}
</h3>
{/* ساب‌تایتل: نام تأمین‌کننده، موقعیت و فاکتور رسمی */}
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
<Link
to={`/suppliers/${q.supplier_id}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold text-muted hover:text-accent truncate no-underline hover:underline transition-colors"
>
🏢 {sup.name}
</Link>
{sup.location && <span className="text-muted/60">· {sup.location}</span>}
{q.is_official && (
<span
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.2 text-[9.5px] font-bold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
فاکتور رسمی
</span>
)}
</div>
</div>
</div>
{/* بخش چپ: بج وضعیت */}
<div className="shrink-0 pt-0.5">{statusBadge}</div>
</div>
{/* ===== بدنه کارت (Body: گرید دو ستونه فشرده متادیتا) ===== */}
<div className="p-3.5 sm:p-4 flex flex-col gap-2.5">
<div className="grid grid-cols-2 gap-2 sm:gap-2.5">
{/* ۱. پروژه */}
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
<Building2 className="h-4 w-4 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-muted">پروژه</span>
<span className="block truncate text-xs font-bold text-ink" title={projectName}>
{projectName}
</span>
</div>
</div>
{/* ۲. مقدار */}
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
<Boxes className="h-4 w-4 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-muted">مقدار</span>
<span className="block truncate text-xs font-bold text-ink tabular-nums" title={quantityDisplay}>
{quantityDisplay}
</span>
</div>
</div>
{/* ۳. تاریخ */}
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
<Calendar className="h-4 w-4 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-muted">تاریخ درخواست</span>
<span className="block truncate text-xs font-bold text-ink tabular-nums">
{fmtDate(q.quote_date)}
</span>
</div>
</div>
{/* ۴. کد پیگیری */}
<div className="flex items-start gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
<Hash className="h-4 w-4 shrink-0 text-muted mt-0.5" />
<div className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-muted">کد پیگیری</span>
<span className="block font-mono font-bold text-[11px] sm:text-xs text-ink break-all select-all leading-tight">
{q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
</span>
</div>
</div>
</div>
{/* سطر قیمت و شرایط پرداخت (در صورت وجود) */}
{total > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-accent-bg/40 dark:bg-accent-bg/10 border border-accent/15 px-3 py-2 text-xs">
<div className="flex items-center gap-1.5">
<CreditCard className="h-3.5 w-3.5 text-accent" />
<span className="text-[11px] text-muted">مبلغ کل:</span>
<span className="text-xs sm:text-sm font-extrabold text-ink tabular-nums">
{groupCommas(total, true)} تومان
</span>
</div>
{ts.title !== "—" && (
<span className="text-[11px] font-medium text-muted truncate">
شرایط: {ts.title} {ts.sub ? `(${ts.sub})` : ""}
</span>
)}
</div>
)}
</div>
{/* ===== فوتر کارت (Footer: اکشن‌های استاندارد با تاچ تارگت حداقل ۴۰px) ===== */}
<div className="flex items-center justify-between gap-2 border-t border-line/70 bg-slate-50/40 dark:bg-zinc-800/20 px-3.5 py-2.5 sm:px-4">
{/* دکمه اصلی مشاهده / بستن جزئیات */}
<button <button
type="button"
onClick={onToggleExpand}
className="min-h-[40px] h-10 flex-1 inline-flex items-center justify-center gap-1.5 rounded-xl border border-line bg-panel hover:bg-slate-50 dark:hover:bg-zinc-800 text-xs font-bold text-ink px-4 transition-all shadow-2xs cursor-pointer select-none active:scale-[0.99]"
>
<span>{expanded ? "بستن جزئیات" : "مشاهده جزئیات"}</span>
<ChevronDown
className={cn("h-4 w-4 transition-transform text-muted duration-200", expanded && "rotate-180 text-accent")}
/>
</button>
{/* دکمه‌های عملیاتی برای کاربران دارای دسترسی نوشتن */}
{canWrite && q.status === "offered" && (
<button
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAccept(); onAccept();
}} }}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform" className="min-h-[40px] h-10 inline-flex items-center justify-center gap-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs font-bold px-3.5 shadow-xs transition-all cursor-pointer active:scale-95 shrink-0"
> >
<Check className="h-3.5 w-3.5" /> <span>پذیرش پیشنهاد</span>
<span>پذیرش</span>
</button> </button>
) : (
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
);
} else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
} else {
acceptedCell = <span className="text-muted">—</span>;
}
// وضعیت رسید کالا
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
const fulfilledItems = activeItems.filter(
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
).length;
const anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
// نشان پیشرفت تکمیل
let fulfillmentBadge: React.ReactNode = null;
if (q.status === "accepted" || q.status === "arrived") {
if (fullyDelivered || q.status === "arrived") {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10.5px] font-bold text-emerald-700 sm:text-[11px]">
✓ تکمیل
</span>
);
} else if (anyReceived) {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10.5px] font-bold text-amber-700 sm:text-[11px]">
رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
</span>
);
} else {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10.5px] font-bold text-blue-700 sm:text-[11px]">
⏳ در راه
</span>
);
}
}
let arrivedCell: React.ReactNode;
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
if (!li.arrived_date) return acc;
const t = new Date(li.arrived_date).getTime();
if (Number.isNaN(t)) return acc;
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
}, null);
if (q.status === "accepted") {
if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
} else if (receivedSum > 0) {
arrivedCell = (
<span className="tabular-nums">
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
</span>
);
} else {
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
}
} else if (q.status === "arrived") {
arrivedCell = (
<span className="tabular-nums">
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
</span>
);
} else {
arrivedCell = <span className="text-muted">—</span>;
}
return (
<div
className={cn(
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
expired ? "border-amber/40" : "border-line hover:border-accent/40",
expanded && "border-accent/60 ring-1 ring-accent/20",
)}
>
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */}
<div
onClick={onToggleExpand}
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0 flex-1">
{/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */}
<div className="flex items-center">
<Link
to={`/suppliers/${q.supplier_id}`}
onClick={(e) => e.stopPropagation()}
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
>
{sup.name}
</Link>
</div>
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{sup.location && (
<span className="truncate text-[10px] text-muted sm:text-[11px]">
{sup.location}
</span>
)} )}
{q.is_official && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
<span>فاکتور رسمی</span>
</span>
)}
</div>
</div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]}
</Badge>
)}
{/* دکمه ویرایش */}
{canWrite && ( {canWrite && (
<button <button
type="button" type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(); onEdit();
}} }}
className="min-h-[40px] h-10 inline-flex items-center justify-center gap-1 rounded-xl border border-line bg-panel hover:bg-slate-50 dark:hover:bg-zinc-800 text-xs font-semibold text-muted hover:text-ink px-3 transition-colors cursor-pointer shrink-0"
title="ویرایش پیشنهاد" title="ویرایش پیشنهاد"
aria-label="ویرایش" aria-label="ویرایش"
> >
<Pencil className="h-3 w-3 text-muted" /> <Pencil className="h-3.5 w-3.5" />
<span className="hidden sm:inline">ویرایش</span> <span className="hidden sm:inline">ویرایش</span>
</button> </button>
)} )}
{/* دکمه فلش اکسپند در هدر */}
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
expanded && "bg-accent-bg text-accent-dark border-accent/40",
)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
</button>
</div>
</div> </div>
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */} {/* ===== بخش جزئیات بازشونده (Expanded Detail) ===== */}
<div
onClick={onToggleExpand}
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
>
{/* ۱. پروژه */}
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
</span>
</div>
</InfoCell>
{/* ۲. اقلام */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? (
activeItems.map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<span key={li.id} className="break-words">
{materialLabel(m)}
</span>
);
})
) : (
<span>—</span>
)}
</div>
{unitWarns.map((w) => (
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
onClick={(e) => e.stopPropagation()}
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
واحد جدید: {w.unit}
</Link>
))}
</InfoCell>
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? (
<div>
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
</div>
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
</div>
) : (
"—"
)}
</InfoCell>
{/* ۴. شرایط معامله */}
<InfoCell label="شرایط معامله" className="col-span-1">
{ts.title !== "—" ? (
<div>
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
{ts.title}
</div>
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
</div>
) : (
<span className="text-muted">—</span>
)}
</InfoCell>
</div>
{/* تاریخ‌ها و اکشن‌ها */}
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
<span className="inline-flex items-center gap-1.5 truncate">
<Package className="h-3 w-3 shrink-0" />
<span>تاریخ:</span>
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
</span>
<span className="inline-flex items-center gap-1.5 truncate">
<CreditCard className="h-3 w-3 shrink-0" />
<span>اعتبار:</span>
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)}
</b>
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
</span>
<span className="inline-flex items-center gap-1.5">
<span>پذیرش:</span>
{acceptedCell}
</span>
<span className="inline-flex items-center gap-1.5 truncate">
<span>رسید:</span>
{arrivedCell}
</span>
</div>
{/* دکمه تمام‌عرض توضیحات بیشتر */}
<button
type="button"
onClick={onToggleExpand}
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
>
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
</button>
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */}
{expanded && ( {expanded && (
<div onClick={(e) => e.stopPropagation()}> <div className="border-t border-line" onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} /> <QuoteDetail store={store} q={q} />
</div> </div>
)} )}
+71 -1
View File
@@ -1,5 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; import {
Package,
CreditCard,
CalendarDays,
Paperclip,
Building2,
Ban,
Hash,
Copy,
Check,
} from "lucide-react";
import { toast } from "sonner";
import type { Attachment, Quote, Store } from "../../lib/types"; import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { import {
@@ -13,6 +24,7 @@ import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram"; import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils"; import { itemProjects } from "../../lib/quoteUtils";
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal"; import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
import { cn } from "../../lib/utils";
export interface QuoteDetailProps { export interface QuoteDetailProps {
store: Store; store: Store;
@@ -24,6 +36,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null); const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() })); const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
const [bump, setBump] = useState(0); const [bump, setBump] = useState(0);
const [copied, setCopied] = useState(false);
const trackingCode = q.id.startsWith("PR-") ? q.id : `PR-${q.id}`;
const handleCopyCode = async () => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(trackingCode);
} else {
const ta = document.createElement("textarea");
ta.value = trackingCode;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
toast.success("کد پیگیری با موفقیت کپی شد");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("کپی کد پیگیری با خطا مواجه شد");
}
};
// پیش‌نمایش پیوست‌ها: کلیک روی نام فایل، فایل را نشان می‌دهد (نه دانلود). // پیش‌نمایش پیوست‌ها: کلیک روی نام فایل، فایل را نشان می‌دهد (نه دانلود).
// بایت‌ها با توکن گرفته می‌شوند چون <img>/<iframe> نمی‌توانند هدر بفرستند. // بایت‌ها با توکن گرفته می‌شوند چون <img>/<iframe> نمی‌توانند هدر بفرستند.
@@ -180,6 +217,39 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
return ( return (
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}> <div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
{/* بخش برجسته و اختصاصی کد پیگیری */}
<div className="mb-3.5 flex flex-wrap items-center justify-between gap-2.5 rounded-xl border border-line bg-panel p-3 sm:px-4 sm:py-3 shadow-2xs">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-bg text-accent shadow-2xs border border-accent/20">
<Hash className="h-4 w-4" />
</div>
<div className="min-w-0">
<span className="block text-[10.5px] font-semibold text-muted">کد پیگیری پیشنهاد</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-xs sm:text-sm font-bold text-ink select-all break-all leading-tight">
{trackingCode}
</span>
</div>
</div>
</div>
{/* دکمه کپی کد پیگیری */}
<button
type="button"
onClick={handleCopyCode}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition-all cursor-pointer shadow-2xs active:scale-95 shrink-0",
copied
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800"
: "border-line bg-slate-50 dark:bg-zinc-800 text-ink hover:bg-slate-100 dark:hover:bg-zinc-700 hover:border-accent/40",
)}
title="کپی کد پیگیری در کلیپ‌بورد"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5 text-muted" />}
<span>{copied ? "کپی شد" : "کپی کد"}</span>
</button>
</div>
<div className="flow-cols"> <div className="flow-cols">
<div className="flow-col flow-items-col"> <div className="flow-col flow-items-col">
<div className="flow-col-h"> <div className="flow-col-h">
+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,26 +200,45 @@ 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";
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"> <td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b> <div className="flex flex-col">
</td> <div className="flex items-center gap-1.5">
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted"> <span className="font-bold text-foreground">
{u.username || "—"} {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>
</td> </td>
{/* ۲. نقش سازمانی */}
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
{canChangeRoles ? ( {canChangeRoles ? (
<select <select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]" 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 ?? ""} value={u.role ?? ""}
disabled={busyId === u.id} disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)} onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
@@ -238,132 +250,19 @@ export function OrganizationPage() {
<option value="supervisor">سرپرست پروژه</option> <option value="supervisor">سرپرست پروژه</option>
</select> </select>
) : ( ) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold"> <span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"} {ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span> </span>
)} )}
</td> </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>
) : (
<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>
))}
</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>
<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"> <td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b> {isAdmin ? (
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span> <span className="text-[11px] text-muted">کامل (ادمین)</span>
</td> ) : canChangeRoles ? (
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select <select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]" 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 ?? ""} value={u.panels?.procurement ?? ""}
disabled={busyId === u.id} disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")} onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
@@ -376,10 +275,14 @@ export function OrganizationPage() {
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span> <span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)} )}
</td> </td>
{/* ۴. پنل انبار */}
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
{canChangeRoles ? ( {isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select <select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]" 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 ?? ""} value={u.panels?.inventory ?? ""}
disabled={busyId === u.id} disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")} onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
@@ -392,8 +295,32 @@ export function OrganizationPage() {
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span> <span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)} )}
</td> </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"> <td className="py-2.5">
{hasPanel ? ( {isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : hasPanel ? (
<div className="w-72"> <div className="w-72">
<ProjectMultiSelect <ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))} options={projects.map((p) => ({ id: p.id, name: p.name }))}
@@ -412,6 +339,256 @@ export function OrganizationPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div>
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
<div>
<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>
{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> </div>
</PageLayout> </PageLayout>
</div> </div>
+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}>
+58 -18
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,12 +102,12 @@ 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) {
@@ -110,7 +117,22 @@ export function WizardPage() {
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>
{/* نوار مراحل استپر */} {/* نوار مراحل استپر */}
+9
View File
@@ -62,6 +62,15 @@ button:disabled {
} }
} }
/* پنهان‌سازی اسکرول‌بار برای چیپ‌های افقی */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ---------- تایپوگرافی ---------- */ /* ---------- تایپوگرافی ---------- */
.num-words { .num-words {
@apply text-muted; @apply text-muted;