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
await db.flush()
project_ids: list = []
if user.role in ("supervisor", "pm"):
projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
project_ids = [p.id for p in projects]
project_id = project_ids[0] if project_ids else None
# لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
panels: dict = {}
panel_rows = (
@@ -166,6 +154,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
@@ -177,6 +166,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
).scalars().all()
]
pm_projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
pm_project_ids_list = [p.id for p in pm_projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(
user=user,
@@ -190,15 +192,22 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM/supervisor user (oldest first)."""
rows = (
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
pm_rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
member_rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
+2 -1
View File
@@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user)
if ctx.role == "supervisor" and not projects:
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track(
"login_success",
+6 -3
View File
@@ -169,11 +169,14 @@ async def create_quote(
):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
member = set(ctx.member_project_ids or [])
wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= member:
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
+25 -11
View File
@@ -17,7 +17,7 @@ import uuid
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import delete, select, update, text
from sqlalchemy import delete, select, update, text, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
@@ -100,7 +100,9 @@ async def user_to_dict(user: User, projects: list[Project] | None = None, db: As
}
if db is not None:
out["panels"] = await _panels_of(db, user.id)
out["member_project_ids"] = await _member_project_ids_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
@@ -112,13 +114,23 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project assigned to the user (supervisor or PM) (oldest first)."""
"""Every project assigned to the user (supervisor or PM) (oldest first).
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
@@ -246,12 +258,12 @@ async def create_user(
async def update_user_role(
user_id,
payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
assign or elevate roles. Revoking (or switching away from «pm») also
unassigns the user from their project."""
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
if payload.role == "admin" and ctx.role != "admin":
raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
@@ -386,13 +398,14 @@ async def assign_user_project(
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین."""
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
@@ -409,10 +422,11 @@ async def set_user_panels(
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head>
<body>
<div id="root"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "دفترچه تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "مدیریت پروژه و تأمین",
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl",
"lang": "fa",
"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.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
if (!user.projects || user.projects.length === 0) {
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
@@ -85,42 +88,137 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
</div>
);
}
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
}
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const panels = user.panels ?? {};
const isAdmin = user.role === "admin";
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند.
function HomeRoute() {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (panels.inventory) return <Navigate to="/inventory" replace />;
if (panels.procurement === "limited") return <RequesterHome />;
// فقط پنل PM: مستقیم به پنل مدیریت پروژه
if (panels.pm && !panels.inventory && !panels.procurement) {
return <Navigate to="/panel/pm" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />;
}
// ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full
// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester)
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") {
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>;
}
if (panels.procurement === "full") return <>{children}</>;
if (panels.procurement === "limited") return <Navigate to="/requester" replace />;
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
@@ -134,25 +232,52 @@ function PmPanelPage() {
);
}
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های میراث pm/supervisor —
// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const allowed =
user?.role === "admin" ||
Boolean(panels.pm) ||
user?.role === "pm" ||
user?.role === "supervisor";
if (!allowed) return <Navigate to="/" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const isAdmin = user?.role === "admin";
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>;
}
@@ -204,6 +329,7 @@ export default function App() {
</RequireAuth>
}
/>
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route
path="/suppliers"
element={
+38 -26
View File
@@ -17,49 +17,61 @@ export function AppSwitcherMenu({
}) {
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const isSupervisor = user?.role === "supervisor";
const panels = user?.panels ?? {};
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود
if (isSupervisor) {
const supervisorItems: MenuItem[] = [
{
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 canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = [];
if (hasInventory) {
items.push({
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
{
label: "مدیریت پروژه",
to: "/panel/pm",
},
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
];
return <PanelMenu title="پنل‌ها" items={supervisorItems} />;
});
}
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const items: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
];
if (canSwitchToProcurement) {
if (hasProcurement && canSwitchToProcurement) {
items.push({
label: "تدارکات",
to: "/",
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement",
});
}
if (hasPm) {
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
}
if (canManage) {
if (canManageOrg) {
items.push({
label: "سازمان‌دهی",
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) });
return res.data;
}
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data;
@@ -1663,9 +1667,9 @@ export const apiV26 = {
patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panels`, { panels }),
putData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
+13 -3
View File
@@ -62,7 +62,7 @@ export function formatMaterialTitle(
const normName = normalize(rawName);
if (normName.startsWith(normBrand)) {
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> = {
"پیشنهاد شده": "offered",
"در انتظار تایید": "offered",
"در انتظار تأیید": "offered",
"پذیرفته شده": "accepted",
"در حال خرید": "accepted",
"رسیده": "arrived",
"تکمیل شده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تکمیل": "arrived",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
"rejected": "rejected",
"رد شده": "rejected",
"expired": "expired",
"منقضی شده": "expired",
};
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));
if (q) {
const projectsText = `${(quote.projects || []).join(" ")} ${quote.project || ""}`;
const hay =
`${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
`${quote.id} PR-${quote.id} ${supObj.name} ${projectsText} ` +
matInfo.map((m) => `${m.name} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false;
}
return true;
+35 -7
View File
@@ -77,7 +77,10 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return;
}
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (user.role === "supervisor" && !hasProjects) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
@@ -86,9 +89,23 @@ export function LoginPage() {
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`);
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقش‌ها → دفتر پیشنهادهای خرید
navigate(user.role === "supervisor" ? "/panel/pm" : "/");
// هدایت پس از ورود بر اساس پنل‌ها یا نقش:
const panels = user.panels ?? {};
if (panels.procurement === "full") {
navigate("/");
} else if (panels.procurement === "limited") {
navigate("/requester");
} else if (panels.inventory) {
navigate("/inventory");
} else if (panels.pm) {
navigate("/panel/pm");
} else if (user.role === "supervisor") {
navigate("/panel/pm");
} else if (user.role === "pm") {
navigate("/inventory");
} else {
navigate("/");
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally {
@@ -277,10 +294,21 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} />
</span>
<div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
<p className="mt-1 text-[12.5px] leading-6 text-muted">
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
</p>
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
مدیریت پروژه
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
تدارکات
</span>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
انبارداری
</span>
</div>
</div>
</div>
@@ -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">
<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">
پذیرش پیشنهاد — {sup.name}
</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>
<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">
<div className="hidden md:block">
<StatsBar
total={stats.total}
offered={stats.offered}
@@ -140,12 +141,14 @@ export function DashboardPage() {
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/>
</div>
<FilterToolbar
filters={filters}
onChange={setFilters}
suppliers={supplierOptions}
sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
stats={stats}
/>
<LedgerTable
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">
{/* هدر */}
<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>
{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">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div>
+138 -97
View File
@@ -1,6 +1,8 @@
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 { fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
export interface Filters {
supplier: string;
@@ -8,29 +10,33 @@ export interface Filters {
search: string;
}
// گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه
// گزینه‌های استاندارد وضعیت برای دراپ‌داون فیلترهای پیشرفته
const STATUS_OPTIONS = [
{ id: "offered", label: "پیشنهاد باز" },
{ id: "accepted", label: "پذیرفته‌شده" },
{ id: "arrived", label: "تحویل‌شده" },
{ id: "offered", label: "در انتظار تایید" },
{ id: "accepted", label: "در حال خرید" },
{ id: "arrived", label: "تکمیل شده" },
];
const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز",
accepted: "پذیرفته‌شده",
arrived: "تحویل‌شده",
offered: "در انتظار تایید",
accepted: "در حال خرید",
arrived: "تکمیل شده",
};
const STATUS_TO_KEY: Record<string, string> = {
"در انتظار تایید": "offered",
"در انتظار تأیید": "offered",
"پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered",
"در حال خرید": "accepted",
"پذیرفته‌شده": "accepted",
"پذیرفته شده": "accepted",
"تکمیل شده": "arrived",
"تحویل‌شده": "arrived",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تحویل": "arrived",
"تکمیل": "arrived",
offered: "offered",
accepted: "accepted",
arrived: "arrived",
@@ -42,6 +48,7 @@ export interface FilterToolbarProps {
suppliers: { id: string; name: string }[];
sortDir: "desc" | "asc";
onToggleSort: () => void;
stats?: { total: number; offered: number; accepted: number; arrived: number };
}
export function FilterToolbar({
@@ -50,97 +57,179 @@ export function FilterToolbar({
suppliers,
sortDir,
onToggleSort,
stats,
}: FilterToolbarProps) {
const [isOpen, setIsOpen] = useState(false);
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 = () => {
onChange({ ...filters, supplier: "", status: "" });
const clearSupplierFilter = () => {
set({ supplier: "" });
};
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
const clearAllFilters = () => {
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 key = STATUS_TO_KEY[val] || val;
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 (
<>
{/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */}
<div className="flex md:hidden items-center gap-2">
{/* اینپوت جستجوی متنی */}
<div className="flex flex-col gap-2.5">
{/* ===== ردیف جستجو و کنترل‌های فیلتر (تک‌ردیف افقی فشرده) ===== */}
<div className="flex items-center gap-2">
{/* فیلد جستجوی متنی پروژه / کالا / تأمین‌کننده */}
<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
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"
placeholder="جستجو در نام، برند، پروژه..."
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="جستجو در پروژه، کالا، تأمین‌کننده..."
value={filters.search}
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>
{/* دکمه فیلترها با بج تعداد انتخاب‌شده */}
{/* دکمه باز کردن فیلترهای پیشرفته (تأمین‌کننده و ...) */}
<button
type="button"
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
? "border-accent bg-accent-bg text-accent"
: "border-line bg-panel text-ink hover:bg-slate-bg"
}`}
title="فیلترها"
? "border-accent bg-accent-bg text-accent-dark ring-1 ring-accent/30"
: "border-line bg-panel text-ink hover:bg-slate-50 dark:hover:bg-zinc-800",
)}
title="فیلترهای پیشرفته"
>
<SlidersHorizontal className="h-4 w-4" />
<span>فیلتر</span>
<span className="hidden xs:inline">فیلترها</span>
{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}
</span>
)}
</button>
{/* دکمه تغییر جهت سورت */}
{/* دکمه تغییر جهت مرتب‌سازی */}
<button
type="button"
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"
title={sortDir === "desc" ? "جدیدترین اول" : "قدیمی‌ترین اول"}
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" ? "مرتب‌سازی: جدیدترین اول" : "مرتب‌سازی: قدیمی‌ترین اول"}
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>
</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 && (
<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="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="flex items-center gap-2">
<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 className="flex items-center gap-3">
{activeFilterCount > 0 && (
{(filters.supplier || filters.status || filters.search) && (
<button
type="button"
onClick={clearFilters}
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
onClick={clearAllFilters}
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
type="button"
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="بستن"
>
<X className="h-5 w-5" />
@@ -149,9 +238,9 @@ export function FilterToolbar({
</div>
{/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5">
<div className="flex flex-col gap-4">
<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
value={filters.supplier}
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
@@ -163,7 +252,7 @@ export function FilterToolbar({
</div>
<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
value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
@@ -179,61 +268,13 @@ export function FilterToolbar({
<button
type="button"
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>
</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 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 { 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 {
isExpired,
materialById,
materialLabel,
formatMaterialTitle,
projectCell,
quoteTotal,
supplierById,
termsheetSummary,
unitWarning,
} from "../../lib/quoteUtils";
import { STATUS_LABEL } from "../../lib/constants";
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
import { Badge } from "../../components/ui/badge";
import { topicByName } from "../../lib/topics";
import { fmtDate, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { cn } from "../../lib/utils";
import { QuoteDetail } from "./QuoteDetail";
@@ -27,318 +37,270 @@ export interface QuoteCardProps {
canWrite?: boolean;
}
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("min-w-0", className)}>
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
</div>
);
}
export function QuoteCard({ store, q, expanded, onToggleExpand, 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 expired = isExpired(q);
const total = quoteTotal(q);
const pc = priceCell(total);
const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = canWrite ? (
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
const primaryLineItem = activeItems[0];
const primaryMaterial = primaryLineItem
? 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
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) => {
e.stopPropagation();
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>
) : (
<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 && (
<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) => {
e.stopPropagation();
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="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3 text-muted" />
<Pencil className="h-3.5 w-3.5" />
<span className="hidden sm:inline">ویرایش</span>
</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
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 Detail) ===== */}
{expanded && (
<div onClick={(e) => e.stopPropagation()}>
<div className="border-t border-line" onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} />
</div>
)}
+71 -1
View File
@@ -1,5 +1,16 @@
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 { api } from "../../lib/api";
import {
@@ -13,6 +24,7 @@ import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils";
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
import { cn } from "../../lib/utils";
export interface QuoteDetailProps {
store: Store;
@@ -24,6 +36,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
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> نمی‌توانند هدر بفرستند.
@@ -180,6 +217,39 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
return (
<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-col flow-items-col">
<div className="flow-col-h">
+55 -11
View File
@@ -1,28 +1,68 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { FilePlus2, Info } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { useAuthStore } from "../../store/auth";
import { useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای
// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود.
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژه‌های تخصیص‌یافته
export function RequesterHome() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const memberIds = new Set(user?.member_project_ids ?? []);
const myProjects = memberIds.size
? projects.filter((p) => memberIds.has(p.id))
: [];
// پروژه‌های کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژه‌های عمومی)
const myProjects = useMemo(() => {
const list: { id: string; name: string }[] = [];
const seen = new Set<string>();
// ۱. پروژه‌های موجود در آرایه user.projects
for (const p of user?.projects ?? []) {
if (p.id && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۲. پروژه‌ی اختصاصی فعال (project_id)
if (user?.project_id && !seen.has(user.project_id)) {
seen.add(user.project_id);
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیص‌یافته" });
}
// ۳. پروژه‌های عضویت member_project_ids تطبیق‌یافته با لیست پروژه‌ها
const memberIdSet = new Set(user?.member_project_ids ?? []);
for (const p of projects) {
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
if (list.length === 0 && user?.project_name) {
const match = projects.find((p) => p.name === user.project_name);
if (match && !seen.has(match.id)) {
list.push({ id: match.id, name: match.name });
}
}
return list;
}, [user, projects]);
return (
<div className="min-h-screen">
<Header title="افزودن استعلام" brand />
<Header
title="افزودن استعلام"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
شما مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
@@ -36,8 +76,8 @@ export function RequesterHome() {
{myProjects.map((p) => (
<Link
key={p.id}
to="/wizard"
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
to={`/wizard?project=${encodeURIComponent(p.name)}`}
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
@@ -50,7 +90,10 @@ export function RequesterHome() {
))}
</div>
<Link to="/wizard" className="btn-line-file no-underline">
<Link
to="/wizard"
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
>
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
@@ -60,3 +103,4 @@ export function RequesterHome() {
</div>
);
}
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { apiV26, type UserRow } from "../../lib/api";
import { api, apiV26, type UserRow } from "../../lib/api";
import type { Project } from "../../lib/types";
import { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
@@ -17,10 +18,12 @@ const ROLE_LABELS: Record<string, string> = {
supervisor: "سرپرست پروژه",
};
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها و پنل‌ها
export function OrganizationPage() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const storeProjects = useProjects();
const [directProjects, setDirectProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
@@ -28,11 +31,25 @@ export function OrganizationPage() {
const [busyId, setBusyId] = useState<string | null>(null);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد؛ مدیران می‌توانند پروژه‌ها را به سرپرستان تخصیص دهند
const canChangeRoles = user?.role === "admin";
const canChangeRoles = canManage;
const fetchProjects = useCallback(async () => {
try {
setLoadingProjects(true);
const list = await api.projects.list();
if (list && Array.isArray(list)) {
setDirectProjects(list);
}
} catch {
// fallback silently
} finally {
setLoadingProjects(false);
}
}, []);
const loadUsers = useCallback(async () => {
try {
setLoadingUsers(true);
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
@@ -43,8 +60,13 @@ export function OrganizationPage() {
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
if (canManage) {
void loadUsers();
void fetchProjects();
}
}, [canManage, loadUsers, fetchProjects]);
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
const syncUsers = async () => {
setSyncing(true);
@@ -57,6 +79,7 @@ export function OrganizationPage() {
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
void fetchProjects();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
@@ -91,39 +114,8 @@ export function OrganizationPage() {
}
};
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id);
try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
const names = nextIds
.map((id) => projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers();
} finally {
setBusyId(null);
}
};
// ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ----------
const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => {
const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
setBusyId(u.id);
const next = { ...(u.panels ?? {}) };
if (level) next[panel] = level;
@@ -157,12 +149,14 @@ export function OrganizationPage() {
const panelLabel = (level: string | undefined) =>
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
// سرپرستان تخصیص‌یافته به یک پروژه
// سرپرستان و مسئولان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) =>
users.filter(
(u) =>
(u.role === "supervisor" || u.role === "pm") &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
(u.projects?.some((p) => p.id === projectId) ||
u.project_id === projectId ||
u.member_project_ids?.includes(projectId)),
);
return (
@@ -172,34 +166,33 @@ export function OrganizationPage() {
brand
menu={<AppSwitcherMenu activeModule="organization" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند.
مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند.
<PageLayout className="flex flex-col gap-5">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌های سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین می‌گردد.
</div>
{/* ---------- بخش ۱: جدول کاربران و نقش‌ها ---------- */}
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقش‌ها و سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[14px] font-bold">👥 کاربران و نقش‌ها</h3>
<h3 className="text-[14px] font-bold">👥 کاربران، نقش‌ها و سطوح دسترسی پنل‌ها</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه
تعیین نقش سازمانی، تخصیص پنل‌ها (تدارکات، انبار، PM) و مشخص نمودن پروژه‌های عضویت کاربر
</p>
</div>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
@@ -207,26 +200,45 @@ export function OrganizationPage() {
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">نام کاربری</th>
<th className="py-2 pe-3">نقش</th>
<th className="py-2 pe-3">پروژه‌های تخصیص‌یافته (سرپرست پروژه)</th>
<th className="py-2">وضعیت</th>
<th className="py-2.5 pe-3">نام و مشخصات</th>
<th className="py-2.5 pe-3">نقش سازمانی</th>
<th className="py-2.5 pe-3">پنل تدارکات</th>
<th className="py-2.5 pe-3">پنل انبار</th>
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
<th className="py-2.5">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
{users.map((u) => {
const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
{/* ۱. نام و نام کاربری */}
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
</td>
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
{u.username || "—"}
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">
{u.first_name} {u.last_name}
</span>
<span className="font-mono text-[10px] text-muted">
({u.username || "—"})
</span>
</div>
{busyId === u.id && (
<span className="text-[10px] text-accent animate-pulse font-medium">
در حال ذخیره تغییرات…
</span>
)}
</div>
</td>
{/* ۲. نقش سازمانی */}
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<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 ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
@@ -238,132 +250,19 @@ export function OrganizationPage() {
<option value="supervisor">سرپرست پروژه</option>
</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 ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
<td className="py-2.5 pe-3">
{u.role === "supervisor" || u.role === "pm" ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<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">
<b>{u.first_name} {u.last_name}</b>
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<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 ?? ""}
disabled={busyId === u.id}
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>
)}
</td>
{/* ۴. پنل انبار */}
<td className="py-2.5 pe-3">
{canChangeRoles ? (
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<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 ?? ""}
disabled={busyId === u.id}
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>
)}
</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">
{hasPanel ? (
{isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
@@ -412,6 +339,256 @@ export function OrganizationPage() {
</tbody>
</table>
</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>
</PageLayout>
</div>
+5 -1
View File
@@ -213,7 +213,11 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
/>
))}
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
<div className="dd-empty">
{canAddProject
? "هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید."
: "هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
+58 -18
View File
@@ -22,6 +22,7 @@ import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { matchMaterial } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier";
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
@@ -54,13 +55,19 @@ export function WizardPage() {
const user = useAuthStore((s) => s.user);
const isLimitedProcurement =
user?.panels?.procurement === "limited" && user?.role !== "admin";
const memberProjectNames = useMemo(
() =>
projects
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
.map((p) => p.name),
[projects, user],
);
const memberProjectNames = useMemo(() => {
const allowedIds = new Set([
...(user?.member_project_ids ?? []),
...(user?.projects ?? []).map((p) => p.id),
...(user?.project_id ? [user.project_id] : []),
]);
const matched = projects
.filter((p) => allowedIds.has(p.id))
.map((p) => p.name);
const directNames = (user?.projects ?? []).map((p) => p.name);
if (user?.project_name) directNames.push(user.project_name);
return [...new Set([...matched, ...directNames])];
}, [projects, user]);
const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote();
const addSupplier = useAddSupplier();
@@ -95,12 +102,12 @@ export function WizardPage() {
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ----------
// ---------- prefill از لینک‌ها (?topic=&supplier=&project=) ----------
useEffect(() => {
const t = params.get("topic");
if (!t) return;
if (t) {
const topic = topicByName(t, topics);
if (!topic) return;
if (topic) {
setSelectedTopic(topic.name);
const sid = params.get("supplier");
if (sid) {
@@ -110,7 +117,22 @@ export function WizardPage() {
setSupplierInvalid(false);
}
}
}, [params, suppliers, topics]);
}
}
const proj = params.get("project");
if (proj) {
setAssignment((cur) => {
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [proj];
}
}
return next;
});
}
}, [params, suppliers, topics, items]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
const completedSteps = useMemo(() => {
@@ -143,11 +165,10 @@ export function WizardPage() {
// ---------- پروژه‌ها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(
isLimitedProcurement
? memberProjectNames
: projects.map((p) => p.name),
);
if (isLimitedProcurement) {
return memberProjectNames;
}
const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => {
if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n));
@@ -155,6 +176,24 @@ export function WizardPage() {
return [...names];
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص می‌یابند
useEffect(() => {
if (isLimitedProcurement && memberProjectNames.length === 1) {
const sole = memberProjectNames[0];
setAssignment((cur) => {
let changed = false;
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [sole];
changed = true;
}
}
return changed ? next : cur;
});
}
}, [isLimitedProcurement, memberProjectNames, items]);
const assign = (tempId: string, proj: string) => {
setAssignment((a) => {
const cur = a[tempId] || [];
@@ -431,8 +470,9 @@ export function WizardPage() {
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/"
backLabel="بازگشت به دفتر"
backTo={isLimitedProcurement ? "/requester" : "/"}
backLabel={isLimitedProcurement ? "بازگشت به استعلام‌ها" : "بازگشت به دفتر"}
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
<PageLayout>
{/* نوار مراحل استپر */}
+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 {
@apply text-muted;