8 Commits
Author SHA1 Message Date
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
Behrad 49dd159fb1 feat: improve line items layout, number inputs and supplier click-to-call 2026-09-22 23:06:17 +03:30
Behrad 972b1b49cb fix(dashboard): restore expand toggle on mobile quote cards and add explicit expand action bar 2026-09-21 19:57:15 +03:30
Behrad b176f0f5ff fix(wizard): optimize stepper and line items layout for mobile 2026-09-21 19:39:12 +03:30
Behrad 9386d4846f feat: add lead time badge, refine mobile supplier click, and integrate termsheet cards in edit modal 2026-09-21 19:26:52 +03:30
63 changed files with 2102 additions and 1181 deletions
+7 -2
View File
@@ -119,10 +119,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
if not casdoor_id: if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization") raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
username = str(claims.get("name") or claims.get("preferred_username") or "")
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None: if user is None:
user = User( user = User(
casdoor_id=casdoor_id, casdoor_id=casdoor_id,
username=username or None,
first_name=str( first_name=str(
claims.get("firstName") claims.get("firstName")
or claims.get("firstname") or claims.get("firstname")
@@ -135,9 +137,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
elif username and not user.username:
user.username = username
await db.flush()
project_ids: list = [] project_ids: list = []
if user.role == "pm": if user.role in ("supervisor", "pm"):
projects = ( projects = (
await db.execute( await db.execute(
select(Project) select(Project)
@@ -159,7 +164,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
async def pm_project_ids(db: AsyncSession, user: User) -> list: async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM user (oldest first).""" """Every project id managed by the given PM/supervisor user (oldest first)."""
rows = ( rows = (
await db.execute( await db.execute(
select(Project.id) select(Project.id)
+1 -1
View File
@@ -174,7 +174,7 @@ _V26_DDL = [
""", """,
""" """
DO $$ BEGIN DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
EXCEPTION WHEN duplicate_object THEN NULL; END $$ EXCEPTION WHEN duplicate_object THEN NULL; END $$
""", """,
""" """
+13 -1
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum( user_role_enum = Enum(
"pm", "procurement_manager", "admin", "pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True, name="user_role", native_enum=True,
) )
quote_status_enum = Enum( quote_status_enum = Enum(
@@ -58,6 +58,7 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
username: Mapped[str | None] = mapped_column(Text)
first_name: Mapped[str] = mapped_column(Text, nullable=False) first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text) phone: Mapped[str | None] = mapped_column(Text)
@@ -148,12 +149,23 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
start_date: Mapped[date | None] = mapped_column(Date) start_date: Mapped[date | None] = mapped_column(Date)
planned_start: Mapped[date | None] = mapped_column(Date)
planned_end: Mapped[date | None] = mapped_column(Date)
actual_start: Mapped[date | None] = mapped_column(Date)
actual_end: Mapped[date | None] = mapped_column(Date)
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
# v2.6: the single PM responsible for this project (confirms arrivals). # v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column( pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL") ForeignKey("users.id", ondelete="SET NULL")
) )
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Quote(Base): class Quote(Base):
+16 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec from app.models.procurement import LineItemSpec
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
_procurement_writer = require_role("admin", "procurement_manager")
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)): async def create_attribute_definition(
payload: AttributeDefinitionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key)) duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None: if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -102,7 +108,10 @@ async def get_attribute_definition(
@router.patch("/{definition_id}") @router.patch("/{definition_id}")
async def update_attribute_definition( async def update_attribute_definition(
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db) definition_id,
payload: AttributeDefinitionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ad = await db.get(AttributeDefinition, definition_id) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
@@ -136,7 +145,11 @@ async def update_attribute_definition(
@router.delete("/{definition_id}") @router.delete("/{definition_id}")
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)): async def delete_attribute_definition(
definition_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ad = await db.get(AttributeDefinition, definition_id) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد") raise NotFoundError("تعریف ویژگی یافت نشد")
+3 -1
View File
@@ -22,7 +22,7 @@ from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict from app.routers.users import _user_projects, user_to_dict
from app.services import analytics from app.services import analytics
from app.utils.errors import ServiceUnavailableError, UnauthorizedError from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth") logger = logging.getLogger("procurement.auth")
@@ -78,6 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token) claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db) ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
if ctx.role == "supervisor" and not projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track( await analytics.track(
"login_success", "login_success",
{"username": payload.username, "role": ctx.role}, {"username": payload.username, "role": ctx.role},
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem from app.models.procurement import LineItem
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_role("admin", "procurement_manager")
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
@@ -70,7 +72,11 @@ async def list_brand_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)): async def create_brand_material(
payload: BrandMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
if await db.get(Brand, payload.brand_id) is None: if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id") raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None: if await db.get(Material, payload.material_id) is None:
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
@router.patch("/{bm_id}") @router.patch("/{bm_id}")
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)): async def update_brand_material(
bm_id,
payload: BrandMaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
@router.delete("/{bm_id}") @router.delete("/{bm_id}")
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)): async def delete_brand_material(
bm_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"]) router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_role("admin", "procurement_manager")
def brand_to_dict(brand: Brand) -> dict: def brand_to_dict(brand: Brand) -> dict:
@@ -42,7 +44,11 @@ async def list_brands(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)): async def create_brand(
payload: BrandCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all() rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows): if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{brand_id}") @router.patch("/{brand_id}")
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)): async def update_brand(
brand_id,
payload: BrandUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
@router.delete("/{brand_id}") @router.delete("/{brand_id}")
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)): async def delete_brand(
brand_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
+4 -3
View File
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"]) router = APIRouter(prefix="/quotes", tags=["files"])
# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود # حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
# برای هر کاربر واردشده آزاد است. # برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin") _attachment_deleter = require_role("procurement_manager", "admin")
_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = { ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
@@ -125,7 +126,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) @router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction # Proforma — no status restriction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) @router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
+21 -23
View File
@@ -77,23 +77,21 @@ async def inventory_scope(
if user is not None: if user is not None:
if user.role is None: if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role == "procurement_manager": if user.role in ("procurement_manager", "pm"):
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="manager", project_id=project_id, actor=user.display_name)
if user.role not in ("pm", "admin"): if user.role == "supervisor":
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
if user.role == "pm":
allowed = list(user.project_ids or []) allowed = list(user.project_ids or [])
if not allowed:
raise ForbiddenError("پروژه تخصیص داده نشده است")
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="pm", project_id=project_id, actor=user.display_name) return Scope(role="supervisor", project_id=project_id, actor=user.display_name)
if not allowed: return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name)
raise ValidationError( if user.role == "admin":
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
)
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ValidationError("نقش نامعتبر است", field="role")
# Dev fallback: header-based identity (fork's placeholder mode) — only # Dev fallback: header-based identity (fork's placeholder mode) — only
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
@@ -128,9 +126,9 @@ async def inventory_scope(
return Scope(role=role, project_id=project_id, actor=actor) return Scope(role=role, project_id=project_id, actor=actor)
def _require_pm(scope: Scope) -> None: def _require_inventory_modifier(scope: Scope) -> None:
if scope.role != ROLE_PM: if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست") raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -215,8 +213,8 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Warehouse) stmt = select(Warehouse)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name) stmt = stmt.order_by(Warehouse.name)
@@ -323,7 +321,7 @@ async def list_inventory(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(InventoryItem) stmt = select(InventoryItem)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id Warehouse.project_id == scope.project_id
) )
@@ -388,7 +386,7 @@ async def list_transactions(
stmt = select(InventoryTransaction).join( stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
@@ -454,7 +452,7 @@ async def inventory_history(
if item is None: if item is None:
raise NotFoundError("موجودی یافت نشد") raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id) wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id: if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute( txs = (await db.execute(
select(InventoryTransaction) select(InventoryTransaction)
@@ -474,17 +472,17 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_require_pm(scope) _require_inventory_modifier(scope)
# No explicit db.begin(): the session may already be inside an implicit # No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db # transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure. # commits on success / rolls back on failure.
with nullcontext(): with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id) wh = await get_warehouse_or_404(db, payload.warehouse_id)
if wh.project_id != scope.project_id: if scope.project_id is not None and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None: if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id != scope.project_id: if scope.project_id is not None and dest.project_id != scope.project_id:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor) txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record( await audit_record(
+16 -2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -14,6 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"], tags=["item-receipts"],
) )
_procurement_writer = require_role("admin", "procurement_manager")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)): async def create_receipt(
quote_id,
line_item_id,
payload: ReceiptCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id) await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id) decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
@router.patch("") @router.patch("")
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)): async def update_receipt(
quote_id,
line_item_id,
payload: ReceiptUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id) await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None: if decision is None:
+18 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -23,6 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"], tags=["item-termsheets"],
) )
_procurement_writer = require_role("admin", "procurement_manager")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_item_termsheet( async def create_item_termsheet(
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
payload: ItemTermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
@@ -93,7 +99,12 @@ async def create_item_termsheet(
@router.patch("/{item_termsheet_id}") @router.patch("/{item_termsheet_id}")
async def update_item_termsheet( async def update_item_termsheet(
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
item_termsheet_id,
payload: ItemTermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
@@ -114,7 +125,11 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}") @router.delete("/{item_termsheet_id}")
async def delete_item_termsheet( async def delete_item_termsheet(
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
item_termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
it = await db.scalar( it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
+9 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_procurement_writer = require_role("admin", "procurement_manager")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
@router.put("") @router.put("")
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)): async def put_line_item_specs(
quote_id,
line_item_id,
payload: SpecsPutIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement — specs not in the payload are DELETED (P1-19).""" """FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
+19 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, Unit from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote from app.models.procurement import ItemDecision, LineItem, Quote
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
_procurement_writer = require_role("admin", "procurement_manager")
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -50,7 +52,12 @@ async def list_line_items(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): async def create_line_item(
quote_id,
payload: LineItemCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -89,7 +96,11 @@ async def get_line_item(
@router.patch("/{line_item_id}") @router.patch("/{line_item_id}")
async def update_line_item( async def update_line_item(
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
payload: LineItemUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
@@ -131,7 +142,12 @@ async def update_line_item(
@router.delete("/{line_item_id}") @router.delete("/{line_item_id}")
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): async def delete_line_item(
quote_id,
line_item_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier from app.models.procurement import Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"]) router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_role("admin", "procurement_manager")
def category_to_dict(category: MaterialCategory) -> dict: def category_to_dict(category: MaterialCategory) -> dict:
@@ -46,7 +48,11 @@ async def list_categories(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)): async def create_category(
payload: MaterialCategoryCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None: if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{category_id}") @router.patch("/{category_id}")
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)): async def update_category(
category_id,
payload: MaterialCategoryUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
@router.delete("/{category_id}") @router.delete("/{category_id}")
async def delete_category(category_id, db: AsyncSession = Depends(get_db)): async def delete_category(
category_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
+36 -7
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import ( from app.models.catalog import (
AttributeDefinition, AttributeDefinition,
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"]) router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_role("admin", "procurement_manager")
def _coerce_filter_value(data_type: str, raw: str): def _coerce_filter_value(data_type: str, raw: str):
@@ -125,7 +127,10 @@ async def list_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_material( async def create_material(
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db) payload: MaterialCreate,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
material = await material_service.create_material(db, payload) material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))} return {"data": await serialize_material(db, material, parse_expand(expand))}
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
@router.patch("/{material_id}") @router.patch("/{material_id}")
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)): async def update_material(
material_id,
payload: MaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
@router.delete("/{material_id}") @router.delete("/{material_id}")
async def delete_material(material_id, db: AsyncSession = Depends(get_db)): async def delete_material(
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -248,7 +262,10 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201) @router.post("/{material_id}/units", status_code=201)
async def create_material_unit( async def create_material_unit(
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db) material_id,
payload: MaterialUnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
await _material_or_404(db, material_id) await _material_or_404(db, material_id)
@@ -287,7 +304,11 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}") @router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit( async def update_material_unit(
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db) material_id,
mu_id,
payload: MaterialUnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
mu = await db.scalar( mu = await db.scalar(
@@ -319,7 +340,12 @@ async def update_material_unit(
@router.delete("/{material_id}/units/{mu_id}") @router.delete("/{material_id}/units/{mu_id}")
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)): async def delete_material_unit(
material_id,
mu_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
mu = await db.scalar( mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
) )
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
@router.put("/{material_id}/attribute-values") @router.put("/{material_id}/attribute-values")
async def put_material_attribute_values( async def put_material_attribute_values(
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db) material_id,
payload: MaterialAttributeValuesPut,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
"""FULL replacement of a material's EAV values (empty array deletes all).""" """FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin(): async with db.begin():
+56 -14
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_role("admin", "procurement_manager")
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = { d = {
"id": str(project.id), "id": str(project.id),
"name": project.name, "name": project.name,
"description": project.description, "location": project.location or "",
"project_manager": project.project_manager or "",
"description": project.description or "",
"start_date": to_date(project.start_date), "start_date": to_date(project.start_date),
"status": project.status or "ACTIVE",
"progress": float(project.progress) if project.progress is not None else 0.0,
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at), "created_at": to_iso(project.created_at),
} }
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
pm = await db.get(User, project.pm_user_id) pm = await db.get(User, project.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif project.project_manager:
d["pm_name"] = project.project_manager
return d return d
@@ -55,7 +62,14 @@ async def list_projects(
): ):
stmt = select(Project) stmt = select(Project)
if q: if q:
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%"))) stmt = stmt.where(
or_(
Project.name.ilike(f"%{q}%"),
Project.description.ilike(f"%{q}%"),
Project.location.ilike(f"%{q}%"),
Project.project_manager.ilike(f"%{q}%"),
)
)
stmt = sort_query(stmt, Project, sort) stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items) pm_map = await _pm_map(db, items)
@@ -64,24 +78,38 @@ async def list_projects(
d = { d = {
"id": str(p.id), "id": str(p.id),
"name": p.name, "name": p.name,
"description": p.description, "location": p.location or "",
"project_manager": p.project_manager or "",
"description": p.description or "",
"start_date": to_date(p.start_date), "start_date": to_date(p.start_date),
"status": p.status or "ACTIVE",
"progress": float(p.progress) if p.progress is not None else 0.0,
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at), "created_at": to_iso(p.created_at),
} }
pm = pm_map.get(p.pm_user_id) pm = pm_map.get(p.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif p.project_manager:
d["pm_name"] = p.project_manager
data.append(d) data.append(d)
return {"data": data, "meta": meta} return {"data": data, "meta": meta}
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)): async def create_project(
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = Project( project = Project(
name=payload.name, name=payload.name.strip(),
description=payload.description, location=payload.location.strip() if payload.location else "",
project_manager=payload.project_manager.strip() if payload.project_manager else "",
description=payload.description.strip() if payload.description else "",
start_date=payload.start_date, start_date=payload.start_date,
planned_start=payload.start_date,
status="ACTIVE",
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
@@ -103,37 +131,51 @@ async def update_project(
request: Request, request: Request,
ctx: UserContext | None = Depends(_optional_ctx), ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
if payload.name is not None: if payload.name is not None:
project.name = payload.name project.name = payload.name.strip()
if payload.location is not None:
project.location = payload.location.strip()
if payload.project_manager is not None:
project.project_manager = payload.project_manager.strip()
if payload.description is not None: if payload.description is not None:
project.description = payload.description project.description = payload.description.strip()
if payload.start_date is not None: if payload.start_date is not None:
project.start_date = payload.start_date project.start_date = payload.start_date
project.planned_start = payload.start_date
if payload.status is not None:
project.status = payload.status
# PM assignment (1:N): one PM per project, a PM may own many projects. # PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel). # Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set: if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager"): if role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است") raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
if payload.pm_user_id is None: if payload.pm_user_id is None:
project.pm_user_id = None project.pm_user_id = None
else: else:
pm_user = await db.get(User, payload.pm_user_id) pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None: if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id") raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role != "pm": if pm_user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id") raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
project.pm_user_id = pm_user.id project.pm_user_id = pm_user.id
if not project.project_manager:
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
await db.flush() await db.flush()
return {"data": await project_to_dict(db, project)} return {"data": await project_to_dict(db, project)}
@router.delete("/{project_id}") @router.delete("/{project_id}")
async def delete_project(project_id, db: AsyncSession = Depends(get_db)): async def delete_project(
project_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
+8 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn from app.schemas.procurement import NoteEventIn
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_role("admin", "procurement_manager")
@router.get("") @router.get("")
@@ -34,7 +36,12 @@ async def list_events(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)): async def create_event(
quote_id,
payload: NoteEventIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
if quote is None: if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد") raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
+52 -9
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ( from app.models.procurement import (
@@ -42,6 +43,9 @@ from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"]) router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_role("admin", "procurement_manager")
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
@@ -156,7 +160,12 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): async def create_quote(
payload: QuoteCreate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.create_quote(db, payload, actor) quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)} return {"data": await serialize_quote_with_children(db, quote)}
@@ -167,7 +176,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
@router.patch("/{quote_id}") @router.patch("/{quote_id}")
async def update_quote( async def update_quote(
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: QuoteUpdate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.update_quote(db, quote_id, payload, actor) quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())} return {"data": await serialize_quote(db, quote, set())}
@@ -175,7 +188,11 @@ async def update_quote(
@router.patch("/{quote_id}/children") @router.patch("/{quote_id}/children")
async def replace_quote_children( async def replace_quote_children(
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: QuoteChildrenReplaceIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
"""Full replacement of a quote's line items + termsheets (edit modal).""" """Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
@@ -188,7 +205,11 @@ async def replace_quote_children(
@router.patch("/{quote_id}/accept") @router.patch("/{quote_id}/accept")
async def accept_quote( async def accept_quote(
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: AcceptQuoteIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.accept_quote(db, quote_id, payload, actor) quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)} return {"data": await _accept_response(db, quote)}
@@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
@router.patch("/{quote_id}/arrive") @router.patch("/{quote_id}/arrive")
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): async def arrive_quote(
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.arrive_quote(db, quote_id, actor) quote = await quote_service.arrive_quote(db, quote_id, actor)
return { return {
"data": { "data": {
@@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
@router.patch("/{quote_id}/arrive-items") @router.patch("/{quote_id}/arrive-items")
async def arrive_items( async def arrive_items(
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: ArriveItemsIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.arrive_items(db, quote_id, payload, actor) quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return { return {
@@ -259,14 +289,23 @@ async def arrive_items(
@router.patch("/{quote_id}/reject") @router.patch("/{quote_id}/reject")
async def reject_quote( async def reject_quote(
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: RejectQuoteIn | None = None,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire") @router.patch("/{quote_id}/expire")
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): async def expire_quote(
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.expire_quote(db, quote_id, actor) quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.delete("/{quote_id}") @router.delete("/{quote_id}")
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)): async def delete_quote(
quote_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
result = await quote_service.delete_quote(db, quote_id) result = await quote_service.delete_quote(db, quote_id)
return {"data": result} return {"data": result}
+20 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -15,6 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"], tags=["schedule-steps"],
) )
_procurement_writer = require_role("admin", "procurement_manager")
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
@@ -57,7 +59,11 @@ async def list_schedule_steps(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_schedule_step( async def create_schedule_step(
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
payload: ScheduleStepCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no step_no = payload.step_no
@@ -84,7 +90,12 @@ async def create_schedule_step(
@router.patch("/{step_id}") @router.patch("/{step_id}")
async def update_schedule_step( async def update_schedule_step(
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
step_id,
payload: ScheduleStepUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
step = await _step_or_404(db, termsheet_id, step_id) step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no: if payload.step_no is not None and payload.step_no != step.step_no:
@@ -109,7 +120,13 @@ async def update_schedule_step(
@router.delete("/{step_id}") @router.delete("/{step_id}")
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)): async def delete_schedule_step(
quote_id,
termsheet_id,
step_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
step = await _step_or_404(db, termsheet_id, step_id) step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar( count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where( select(func.count()).select_from(TermsheetScheduleStep).where(
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import StandardConversion from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_role("admin", "procurement_manager")
def sc_to_dict(sc: StandardConversion) -> dict: def sc_to_dict(sc: StandardConversion) -> dict:
@@ -41,7 +43,11 @@ async def list_standard_conversions(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)): async def create_standard_conversion(
payload: StandardConversionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar( duplicate = await db.scalar(
select(StandardConversion.id).where( select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit, StandardConversion.from_unit == payload.from_unit,
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{sc_id}") @router.patch("/{sc_id}")
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)): async def update_standard_conversion(
sc_id,
payload: StandardConversionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
@router.delete("/{sc_id}") @router.delete("/{sc_id}")
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): async def delete_standard_conversion(
sc_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
+12 -2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial from app.models.procurement import Supplier, SupplierMaterial
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_role("admin", "procurement_manager")
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(link: SupplierMaterial, material: Material | None = None,
@@ -63,7 +65,10 @@ async def list_supplier_materials(
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material( async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) supplier_id,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
@@ -72,7 +77,12 @@ async def create_supplier_material(
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)): async def delete_supplier_material(
supplier_id,
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id) await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier from app.models.procurement import Quote, Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"]) router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_role("admin", "procurement_manager")
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -71,7 +73,11 @@ async def list_suppliers(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)): async def create_supplier(
payload: SupplierCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await _category_or_404(db, payload.category_id) category = await _category_or_404(db, payload.category_id)
supplier = Supplier( supplier = Supplier(
name=payload.name, name=payload.name,
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{supplier_id}") @router.patch("/{supplier_id}")
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)): async def update_supplier(
supplier_id,
payload: SupplierUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
@router.delete("/{supplier_id}") @router.delete("/{supplier_id}")
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)): async def delete_supplier(
supplier_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
+24 -4
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_role("admin", "procurement_manager")
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -80,7 +82,12 @@ async def list_termsheets(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)): async def create_termsheet(
quote_id,
payload: TermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -110,7 +117,11 @@ async def get_termsheet(
@router.patch("/{termsheet_id}") @router.patch("/{termsheet_id}")
async def update_termsheet( async def update_termsheet(
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
payload: TermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered": if ts.status != "offered":
@@ -127,7 +138,11 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/status") @router.patch("/{termsheet_id}/status")
async def update_termsheet_status( async def update_termsheet_status(
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
payload: TermsheetStatusUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -139,7 +154,12 @@ async def update_termsheet_status(
@router.delete("/{termsheet_id}") @router.delete("/{termsheet_id}")
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)): async def delete_termsheet(
quote_id,
termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec from app.models.procurement import LineItem, LineItemSpec
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"]) router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_role("admin", "procurement_manager")
def unit_full_to_dict(unit: Unit) -> dict: def unit_full_to_dict(unit: Unit) -> dict:
@@ -45,7 +47,11 @@ async def list_units(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)): async def create_unit(
payload: UnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None: if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@router.patch("/{unit_id}") @router.patch("/{unit_id}")
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)): async def update_unit(
unit_id: int,
payload: UnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
@router.delete("/{unit_id}") @router.delete("/{unit_id}")
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)): async def delete_unit(
unit_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
+47 -14
View File
@@ -17,7 +17,7 @@ import uuid
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select, update from sqlalchemy import select, update, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role from app.auth import UserContext, get_current_user, require_role
@@ -41,6 +41,8 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه", "pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات", "procurement_manager": "مدیر تدارکات",
"admin": "ادمین", "admin": "ادمین",
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
"supervisor": "سرپرست پروژه",
} }
@@ -52,6 +54,7 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
return { return {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
"username": user.username,
"first_name": user.first_name, "first_name": user.first_name,
"last_name": user.last_name, "last_name": user.last_name,
"phone": user.phone, "phone": user.phone,
@@ -65,15 +68,15 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin OR procurement manager (the same person) — organization management.""" """Admin, procurement manager, or project manager — organization management."""
if ctx.role not in ("admin", "procurement_manager"): if ctx.role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]: async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project the user manages as PM (oldest first; [] for non-PMs).""" """Every project assigned to the user (supervisor or PM) (oldest first)."""
if user.role != "pm": if user.role not in ("supervisor", "pm"):
return [] return []
rows = ( rows = (
await db.execute( await db.execute(
@@ -148,13 +151,13 @@ async def list_users(
): ):
"""Every user (with role + assigned projects) — Organization panel.""" """Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"} supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {} by_user: dict = {}
if pm_ids: if supervisor_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(pm_ids)) .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
@@ -204,7 +207,7 @@ async def update_user_role(
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
previous_role = user.role previous_role = user.role
if user.role == "pm" and payload.role != "pm": if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
user.role = payload.role user.role = payload.role
await db.flush() await db.flush()
@@ -231,8 +234,15 @@ async def sync_casdoor_users(
continue continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid)) existing = await db.scalar(select(User).where(User.casdoor_id == cid))
if existing is None: if existing is None:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
by_uname = await db.scalar(select(User).where(User.username == uname))
if by_uname is not None:
by_uname.casdoor_id = cid
continue
db.add(User( db.add(User(
casdoor_id=cid, casdoor_id=cid,
username=uname,
first_name=str( first_name=str(
cu.get("firstName") cu.get("firstName")
or cu.get("displayName") or cu.get("displayName")
@@ -243,16 +253,20 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None, phone=str(cu.get("phone") or "") or None,
)) ))
created += 1 created += 1
elif not existing.username:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
existing.username = uname
await db.flush() await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"} supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {} by_user: dict = {}
if pm_ids: if supervisor_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(pm_ids)) .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
@@ -282,11 +296,16 @@ async def assign_user_project(
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
if user.role != "pm": if user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
if payload.project_id is None: if payload.project_id is None:
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
if user.username:
await db.execute(
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
{"uname": user.username}
)
await db.flush() await db.flush()
return {"data": user_to_dict(user)} return {"data": user_to_dict(user)}
@@ -294,5 +313,19 @@ async def assign_user_project(
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id project.pm_user_id = user.id
if user.username:
await db.execute(
text("""
INSERT INTO project_assignments (id, user_id, project_id)
SELECT gen_random_uuid(), pu.id, :pid
FROM pm_users pu
WHERE pu.username = :uname
AND NOT EXISTS (
SELECT 1 FROM project_assignments pa
WHERE pa.user_id = pu.id AND pa.project_id = :pid
)
"""),
{"pid": project.id, "uname": user.username}
)
await db.flush() await db.flush()
return {"data": user_to_dict(user, await _user_projects(db, user))} return {"data": user_to_dict(user, await _user_projects(db, user))}
+7 -2
View File
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
class ProjectCreate(APIModel): class ProjectCreate(APIModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
class ProjectUpdate(APIModel): class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1) name: str | None = Field(default=None, min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
status: str | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects). # PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM. # Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None pm_user_id: UUID | None = None
@@ -203,13 +208,13 @@ class UserCreate(APIModel):
last_name: str = "" last_name: str = ""
phone: str | None = None phone: str | None = None
casdoor_id: str | None = None casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserRoleUpdate(APIModel): class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment).""" """role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserProjectUpdate(APIModel): class UserProjectUpdate(APIModel):
+3
View File
@@ -248,6 +248,9 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks: networks:
- traefik_network - traefik_network
- default - default
+112 -6
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects"; import { RequirePmProjects } from "./components/RequirePmProjects";
@@ -38,7 +38,7 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
); );
const OrganizationPage = lazy(() => const OrganizationPage = lazy(() =>
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
); );
const AdminPanelPage = lazy(() => const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
@@ -55,17 +55,102 @@ function Loading() {
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم. // همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { pathname } = useLocation();
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
if (!user.projects || user.projects.length === 0) {
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">
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
شما به عنوان سرپرست پروژه در سیستم ثبت شده‌اید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است.
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
</p>
<button
type="button"
onClick={() => {
useAuthStore.getState().logout();
window.location.href = "/login";
}}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
}
}
return <>{children}</>; return <>{children}</>;
} }
// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر // صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات
function HomeRoute() { function HomeRoute() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role === "pm") return <Navigate to="/inventory" replace />; if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
return <DashboardPage />; return <DashboardPage />;
} }
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") {
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
}
return <>{children}</>;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() {
return (
<iframe
src="/pm/"
title="مدیریت پروژه (PM)"
className="h-dvh w-full border-0"
/>
);
}
// دسترسی پنل PM: همه نقش‌ها دسترسی دارند
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) return <Navigate to="/" 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 />;
return <>{children}</>;
}
// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند)
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "supervisor") {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط می‌تواند موجودی تغییر دهد)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role === "supervisor") {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@@ -84,7 +169,9 @@ export default function App() {
path="/wizard" path="/wizard"
element={ element={
<RequireAuth> <RequireAuth>
<RequireProcurementWrite>
<WizardPage /> <WizardPage />
</RequireProcurementWrite>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -164,7 +251,9 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<RequireInventoryChange>
<ChangeInventoryPage /> <ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
@@ -184,18 +273,24 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<RequireWarehouseManage>
<WarehousesPage /> <WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
<Route <Route
path="/inventory/org" path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequireOrganization>
<OrganizationPage /> <OrganizationPage />
</RequirePmProjects> </RequireOrganization>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -207,6 +302,17 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
element={
<RequireAuth>
<RequirePmPanel>
<PmPanelPage />
</RequirePmPanel>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+53 -29
View File
@@ -1,48 +1,72 @@
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu"; import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu"; import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) — // منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر) // ۱. مدیریت انبار (/inventory)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج. // ۲. مدیریت تدارکات (/)
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی // ۳. مدیریت پروژه (/panel/pm)
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و // ۴. سازمان‌دهی (/organization)
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
export function AppSwitcherMenu({ export function AppSwitcherMenu({
activeModule, activeModule,
canSwitchToProcurement = true, canSwitchToProcurement = true,
}: { }: {
activeModule: "warehouse" | "procurement"; activeModule: "warehouse" | "procurement" | "organization";
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد canSwitchToProcurement?: boolean;
}) { }) {
const logout = useLogout(); const logout = useLogout();
const { pathname } = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const setActiveProject = useAuthStore((s) => s.setActiveProject); const isSupervisor = user?.role === "supervisor";
const items: MenuItem[] = [];
const isPm = user?.role === "pm"; // سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود
const pmProjects = isPm ? user?.projects ?? [] : []; if (isSupervisor) {
const projectChildren: MenuItem[] | undefined = const supervisorItems: MenuItem[] = [
activeModule === "warehouse" && isPm {
? pmProjects.map((p) => ({ label: "انبار",
label: p.name, to: "/inventory",
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض می‌شود active: activeModule === "warehouse",
active: p.id === user?.project_id, },
onClick: () => setActiveProject(p.id), {
})) label: "مدیریت پروژه",
: undefined; 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 (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) { if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" }); items.push({
label: "تدارکات",
to: "/",
active: activeModule === "procurement",
});
} }
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
if (canManage) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
active: activeModule === "organization",
});
} }
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: logout }); items.push({ label: "خروج از حساب", onClick: logout });
return <PanelMenu title="پنل‌ها" items={items} />; return <PanelMenu title="پنل‌ها" items={items} />;
+4 -4
View File
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return ( return (
<div className="flex items-start gap-3 w-full my-2"> <div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ */} {/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-36 shrink-0"> <div className="w-28 shrink-0">
<JalaliDatePicker <JalaliDatePicker
value={milestone.when_iso} value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
/> />
</div> </div>
{/* کادر ورود مبلغ و حروف زیر آن */} {/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<NumberInput <NumberInput
value={milestone.amount} value={milestone.amount}
+23 -17
View File
@@ -7,9 +7,11 @@ export interface NumberInputProps {
onChange: (n: number | null) => void; onChange: (n: number | null) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined; inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد) maxDecimals?: number; // حداکثر رقم اعشار
disabled?: boolean; disabled?: boolean;
} }
@@ -19,6 +21,8 @@ export function NumberInput({
onChange, onChange,
placeholder, placeholder,
className, className,
inputClassName,
align = "right",
showWords = false, showWords = false,
inputMode = "numeric", inputMode = "numeric",
maxDecimals, maxDecimals,
@@ -36,8 +40,6 @@ export function NumberInput({
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : "")); const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
// همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل).
// null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود.
useEffect(() => { useEffect(() => {
const cur = parseNum(text); const cur = parseNum(text);
if (value != null && value !== cur) { if (value != null && value !== cur) {
@@ -60,19 +62,28 @@ export function NumberInput({
const n = parseNum(text); const n = parseNum(text);
const alignClass =
align === "center"
? "!text-center text-center"
: align === "left"
? "!text-left text-left"
: "!text-right text-right";
return ( return (
<div className={cn("min-w-0", className)}> <div className={cn("min-w-0 w-full", className)}>
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60" className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
inputMode={inputMode} inputMode={inputMode}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw); const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw); const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) { if (parsed != null && !trailingSep) {
@@ -85,16 +96,11 @@ export function NumberInput({
}} }}
onBlur={() => commit(text)} onBlur={() => commit(text)}
/> />
{showWords && ( {showWords && n != null && (
<span className={cn("num-pill", n == null && "empty")}> <div className="mt-1 block text-right text-[11px] text-muted">
{n != null ? ( <span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<> <span className="text-muted">({toWords(n)})</span>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span> </div>
</>
) : (
""
)}
</span>
)} )}
</div> </div>
); );
+17
View File
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
export interface MenuItem { export interface MenuItem {
label?: string; label?: string;
to?: string; to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean; active?: boolean;
divider?: boolean; divider?: boolean;
onClick?: () => void; onClick?: () => void;
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</div> </div>
)} )}
</div> </div>
) : it.href ? (
<a
key={i}
href={it.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
<span aria-hidden className="text-[10px] opacity-60">↗</span>
</a>
) : it.to ? ( ) : it.to ? (
<Link <Link
key={i} key={i}
@@ -16,11 +16,11 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects); const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout(); const logout = useLogout();
const isPm = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok"); const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => { useEffect(() => {
if (!user || user.role !== "pm") { if (!user || user.role !== "supervisor") {
setState("ok"); setState("ok");
return; return;
} }
+22 -1
View File
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ChevronRight, ClipboardList } from "lucide-react"; import { ChevronRight, ClipboardList } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useAuthStore, type AuthUser } from "../../store/auth";
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
export function userSubtitle(user: AuthUser | null | undefined): string {
if (!user) return "";
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const username = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
}
interface HeaderProps { interface HeaderProps {
title: string; title: string;
@@ -18,6 +35,10 @@ interface HeaderProps {
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) { export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user);
const userSub = userSubtitle(user);
const displaySub = sub !== undefined ? sub : userSub;
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛ // اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛
// اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم. // اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم.
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1> <h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
{sub && <div className="text-[11px] text-muted">{sub}</div>} {displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
</div> </div>
</div> </div>
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div> <div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
@@ -8,7 +8,6 @@ import {
PackageSearch, PackageSearch,
Scale, Scale,
Truck, Truck,
UsersRound,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch }, { to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
]; ];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ) // تب‌های پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [ const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true }, { to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays }, { to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch }, { to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
]; ];
function TabBar({ tabs }: { tabs: TabDef[] }) { function TabBar({ tabs }: { tabs: TabDef[] }) {
@@ -81,6 +80,10 @@ export function MobileTabBar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null; if (pathname === "/login" || pathname === "/admin") return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
if (role === "supervisor") return null;
const isPM = role === "pm"; const isPM = role === "pm";
const isOffice = role === "admin" || role === "procurement_manager"; const isOffice = role === "admin" || role === "procurement_manager";
+1 -1
View File
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes"; export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials"; export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects"; export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits"; export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
+12
View File
@@ -21,3 +21,15 @@ export function useAddProject() {
onSettled: () => invalidate(), onSettled: () => invalidate(),
}); });
} }
export function useDeleteProject() {
const deleteProject = useAppStore((s) => s.deleteProject);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.projects.delete(id),
onMutate: (id) => deleteProject(id),
onSettled: () => invalidate(),
});
}
+44 -9
View File
@@ -87,7 +87,7 @@ function handle401(path: string, res: Response): void {
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند. // ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null { function activeProjectId(): string | null {
const { user } = useAuthStore.getState(); const { user } = useAuthStore.getState();
return user?.role === "pm" ? user.project_id : null; return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null;
} }
function projectQuery(projectId?: string | null): string { function projectQuery(projectId?: string | null): string {
@@ -323,6 +323,12 @@ interface RawSupplier {
interface RawProject { interface RawProject {
id: string; id: string;
name: string; name: string;
location?: string | null;
project_manager?: string | null;
description?: string | null;
start_date?: string | null;
status?: string | null;
progress?: number | null;
} }
interface RawSpec { interface RawSpec {
attribute_key: string; attribute_key: string;
@@ -1131,11 +1137,36 @@ export const api = {
projects: { projects: {
list: async (): Promise<Project[]> => { list: async (): Promise<Project[]> => {
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`); const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
return raw.map((p) => ({ id: p.id, name: p.name })); return raw.map((p) => ({
id: p.id,
name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
start_date: p.start_date,
status: p.status || "ACTIVE",
progress: p.progress != null ? Number(p.progress) : 0,
}));
}, },
create: async (p: Project): Promise<Project> => { create: async (p: Project): Promise<Project> => {
const raw = await postData<RawProject>("/projects", { name: p.name }); const raw = await postData<RawProject>("/projects", {
return { id: raw.id, name: raw.name }; name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
});
return {
id: raw.id,
name: raw.name,
location: raw.location || "",
project_manager: raw.project_manager || "",
description: raw.description || "",
status: raw.status || "ACTIVE",
progress: raw.progress != null ? Number(raw.progress) : 0,
};
},
delete: async (id: string): Promise<void> => {
await deleteData(`/projects/${id}`);
}, },
}, },
@@ -1525,10 +1556,11 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
export interface UserRow { export interface UserRow {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string | null;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
@@ -1553,10 +1585,11 @@ export const apiV26 = {
user: { user: {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
@@ -1570,6 +1603,7 @@ export const apiV26 = {
expires_in: res.data.expires_in, expires_in: res.data.expires_in,
user: { user: {
...u, ...u,
username: u.username || u.casdoor_id,
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id, display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
}, },
}; };
@@ -1589,14 +1623,15 @@ export const apiV26 = {
users: { users: {
me: async (): Promise<import("../store/auth").AuthUser> => { me: async (): Promise<import("../store/auth").AuthUser> => {
const data = await getData<{ const data = await getData<{
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
project_id: string | null; project_name: string | null; project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[]; projects?: { id: string; name: string }[];
}>("/users/me"); }>("/users/me");
return { return {
id: data.id, id: data.id,
casdoor_id: data.casdoor_id, casdoor_id: data.casdoor_id,
username: data.username || data.casdoor_id,
first_name: data.first_name, first_name: data.first_name,
last_name: data.last_name, last_name: data.last_name,
phone: data.phone, phone: data.phone,
@@ -1620,7 +1655,7 @@ export const apiV26 = {
first_name: string; last_name?: string; phone?: string | null; first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null; casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): Promise<UserRow> => postData<UserRow>("/users", body), }): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> => updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }), patchData<UserRow>(`/users/${id}`, { role }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> => assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }), postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
+6
View File
@@ -31,6 +31,12 @@ export interface Material {
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
location?: string;
project_manager?: string;
description?: string;
start_date?: string | null;
status?: string;
progress?: number;
} }
// مرحله‌ی پرداخت // مرحله‌ی پرداخت
+36 -1
View File
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه", pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات", procurement_manager: "مدیر تدارکات",
admin: "ادمین", admin: "ادمین",
supervisor: "سرپرست پروژه",
}; };
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و // پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و
@@ -80,6 +81,26 @@ export function AdminPanel() {
} }
}; };
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
const toggleSupervisor = async (u: UserRow) => {
setBusyId(u.id);
const target = u.role === "supervisor" ? null : "supervisor";
try {
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
toast.success(
target === "supervisor"
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
);
await load();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
if (!isAdmin) { if (!isAdmin) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -129,12 +150,13 @@ export function AdminPanel() {
<th className="py-2 pe-3">نام کامل</th> <th className="py-2 pe-3">نام کامل</th>
<th className="py-2 pe-3">شناسه Casdoor</th> <th className="py-2 pe-3">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th> <th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت مدیر تدارکات</th> <th className="py-2">مدیریت نقش‌ها</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => { {users.map((u) => {
const isPMgr = u.role === "procurement_manager"; const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return ( return (
<tr key={u.id} className="border-b border-line/50"> <tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold"> <td className="py-2 pe-3 font-semibold">
@@ -147,6 +169,7 @@ export function AdminPanel() {
</span> </span>
</td> </td>
<td className="py-2"> <td className="py-2">
<div className="flex flex-wrap items-center gap-1.5">
<button <button
disabled={busyId === u.id} disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)} onClick={() => void toggleProcurementManager(u)}
@@ -158,6 +181,18 @@ export function AdminPanel() {
> >
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"} {busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button> </button>
<button
disabled={busyId === u.id}
onClick={() => void toggleSupervisor(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isSup
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
</button>
</div>
</td> </td>
</tr> </tr>
); );
+6 -2
View File
@@ -77,14 +77,18 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return; return;
} }
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
setToken(access_token); setToken(access_token);
setUser(user); setUser(user);
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*) // تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
identifyUser({ id: user.id, name: user.display_name, role: user.role }); identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role }); track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`); toast.success(`خوش آمدید، ${user.display_name}`);
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید // سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقش‌ها → دفتر پیشنهادهای خرید
navigate(user.role === "pm" ? "/inventory" : "/"); navigate(user.role === "supervisor" ? "/panel/pm" : "/");
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود"); toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally { } finally {
+10 -1
View File
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal"; import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
import { useAuthStore } from "../../store/auth";
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
const user = useAuthStore((s) => s.user);
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
@@ -85,7 +89,7 @@ export function DashboardPage() {
return ( return (
<div className="min-h-screen pb-20 md:pb-6"> <div className="min-h-screen pb-20 md:pb-6">
<Header <Header
title="دفتر پیشنهادهای خرید" title="مدیریت تدارکات"
brand brand
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
@@ -114,6 +118,7 @@ export function DashboardPage() {
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
{canWrite && (
<Link <Link
to="/wizard" to="/wizard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline" className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
@@ -121,6 +126,7 @@ export function DashboardPage() {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span>افزودن پیشنهاد جدید</span> <span>افزودن پیشنهاد جدید</span>
</Link> </Link>
)}
</div> </div>
} }
/> />
@@ -148,10 +154,12 @@ export function DashboardPage() {
onToggleExpand={toggleExpand} onToggleExpand={toggleExpand}
onAccept={setAcceptId} onAccept={setAcceptId}
onEdit={setEditId} onEdit={setEditId}
canWrite={canWrite}
/> />
</PageLayout> </PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */} {/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
{canWrite && (
<Link <Link
to="/wizard" to="/wizard"
aria-label="افزودن پیشنهاد جدید" aria-label="افزودن پیشنهاد جدید"
@@ -159,6 +167,7 @@ export function DashboardPage() {
> >
<Plus className="h-7 w-7" /> <Plus className="h-7 w-7" />
</Link> </Link>
)}
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} /> <AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} /> <EditModal quoteId={editId} onClose={() => setEditId(null)} />
+157 -158
View File
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { MilestoneRow } from "../../components/MilestoneRow"; import { TermsheetCard } from "../../components/TermsheetCard";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]); const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]); const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]); const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null); const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return { return {
key: li.id, key: li.id,
origLi: li, origLi: li,
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
// دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست)
cat: m.name || "", cat: m.name || "",
brand: m.brand || "", brand: m.brand || "",
specs: materialSpecs(m), specs: materialSpecs(m),
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice") .list(quoteId, "invoice")
.then((list) => alive && setInvoices(list)) .then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([])); .catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => { return () => {
alive = false; alive = false;
}; };
}, [quoteId]); }, [quoteId]);
if (!q || !ready) return null; const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const tsLabel = (card: EditTsCard, idx: number) => { const getCoveredIds = (card: EditTsCard) => {
const orig = q.termsheets.find((t) => t.id === card.domId); const cardId = card.domId || card.key;
return orig?.label || `گزینه ${faNum(idx + 1)}`; const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
}; };
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const patchItem = (key: string, patch: Partial<EditItemRow>) => { const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
}; };
const addItemRow = () => { const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [ setItemRows((rows) => [
...rows, ...rows,
{ {
key: "new-" + Math.random().toString(36).slice(2, 9), key: newKey,
cat: "", cat: "",
brand: "", brand: "",
specs: [], specs: [],
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "", unit: "",
price: null, price: null,
total: null, total: null,
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [], tsDomIds: defaultTsId ? [defaultTsId] : [],
acceptedTsId: null, acceptedTsId: null,
rejected: false, rejected: false,
restore: true, restore: true,
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const addTsCard = () => { const addTsCard = () => {
setTsCards((cards) => [ setTsCards((cards) => [
...cards, ...cards,
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, {
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
]); ]);
}; };
const removeTsCard = (key: string) => { const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key)); setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
}; };
const confirm = async () => { const confirm = async () => {
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects]; next.projects = [...selectedProjects];
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
? domId ? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId; tsIdMap[domId] = realId;
tsIdMap[card.key] = realId;
const orig = next.termsheets.find((t) => t.id === realId); const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`; const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered"; const st = orig?.status || "offered";
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const total = row.total; const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
// هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد)
let material = matchMaterial(materialsCopy, cat, brand, specs); let material = matchMaterial(materialsCopy, cat, brand, specs);
let materialId: string; let materialId: string;
if (material) { if (material) {
@@ -322,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!rejected) { if (!rejected) {
row.tsDomIds.forEach((domId) => { row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId; const realId = tsIdMap[domId] || domId;
if (realId) termsheet_ids.push(realId); if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
}); });
if (!termsheet_ids.length && next.termsheets.length) { if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id); termsheet_ids.push(next.termsheets[0].id);
@@ -359,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const referencedTs = new Set<string>(); const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null; li.accepted_ts_id = null;
@@ -406,7 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<> <>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl"> <DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
{/* هدر سفید و تمیز */} {/* هدر */}
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right"> <DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle> <DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5"> <div className="text-[12px] text-gray-500 truncate mt-0.5">
@@ -421,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
)} )}
{/* تب‌بار سفید و استاندارد */} {/* تب‌بار */}
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0"> <div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
<button <button
type="button" type="button"
@@ -492,12 +546,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</button> </button>
</div> </div>
{/* محتوای مودال — پس‌زمینه کاملاً سفید */} {/* محتوا */}
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800"> <div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
{/* تب ۱: اقلام و مشخصات */} {/* تب ۱: اقلام */}
{activeTab === "items" && ( {activeTab === "items" && (
<div className="space-y-4"> <div className="space-y-4">
{/* کارت تأمین‌کننده و پروژه */}
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3"> <div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
@@ -536,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
{/* لیست اقلام */} {/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span> <span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
@@ -573,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5"> {/* ردیف ۱: متریال و برند */}
<div className="flex flex-col gap-1"> <div className="grid grid-cols-2 gap-2.5">
<label className="text-[11px] font-semibold text-gray-600">متریال</label> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="نام متریال" placeholder="نام متریال"
value={row.cat} value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })} onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600">برند</label> <label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="برند" placeholder="برند"
value={row.brand} value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })} onChange={(e) => patchItem(row.key, { brand: e.target.value })}
@@ -597,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2.5"> {/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label> <label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox <MultiSelectCombobox
disabled={hasReceipts} disabled={hasReceipts}
values={row.specs} values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })} onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions} options={specOptions}
placeholder="مشخصه" placeholder="انتخاب مشخصات..."
className="min-w-0"
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* بخش مقادیر و محاسبات مالی */}
<label className="text-[11px] font-semibold text-gray-600">واحد</label> <div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
<input {/* ردیف ۳: تعداد + واحد سنجش */}
disabled={hasReceipts} <div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" <div className="flex flex-col gap-1 min-w-0">
placeholder="واحد سنجش" <label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div>
{/* جعبه ۳ عددی مقادیر */}
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.qty} value={row.qty}
onChange={(qty) => { onChange={(qty) => {
patchItem(row.key, { patchItem(row.key, {
qty, qty,
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total, total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
}); });
}} }}
placeholder="تعداد" placeholder="مقدار"
className="!text-right text-right"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label> <label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد (شاخه، متر، کیلو...)"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.price} value={row.price}
onChange={(price) => { onChange={(price) => {
patchItem(row.key, { patchItem(row.key, {
price, price,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total, total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
}); });
}} }}
placeholder="واحد" placeholder="قیمت هر واحد"
showWords
maxDecimals={2} maxDecimals={2}
className="!text-right text-right"
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label> <div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.total} value={row.total}
@@ -666,14 +730,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price, t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
}); });
}} }}
placeholder="جمع" placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
/> />
</div> </div>
</div> </div>
{/* شرایط پرداخت برای این قلم */} {row.rejected && (
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
{row.rejected ? (
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer"> <label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -683,46 +748,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/> />
بازگرداندن این قلم به معامله بازگرداندن این قلم به معامله
</label> </label>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, cIdx) => {
const isSelected = row.tsDomIds.includes(card.domId || card.key);
return (
<label
key={card.key}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
isSelected
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
hasReceipts && "cursor-not-allowed opacity-60",
)}
>
<input
type="checkbox"
disabled={hasReceipts}
className="accent-blue-600 h-3.5 w-3.5 rounded"
checked={isSelected}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
<span>{tsLabel(card, cIdx)}</span>
</label>
);
})}
</div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
))} ))}
{!hasReceipts && ( {!hasReceipts && (
@@ -741,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
)} )}
{/* تب ۲: شرایط پرداخت */} {/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
{activeTab === "terms" && ( {activeTab === "terms" && (
<div className="space-y-3.5"> <div className="space-y-3.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div> <div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
<div className="text-[11.5px] text-gray-500 mt-0.5">
روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
</div>
</div>
{!hasReceipts && ( {!hasReceipts && (
<Button <Button
type="button" type="button"
@@ -762,72 +796,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tsCards.map((card, idx) => ( {tsCards.map((card, idx) => (
<div <TermsheetCard
key={card.key} key={card.key}
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs" index={idx + 1}
> milestones={card.milestones}
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5"> onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
<div className="flex items-center gap-1.5"> items={itemOptions}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600"> coveredIds={getCoveredIds(card)}
{faNum(idx + 1)} onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
</span> onRemove={() => removeTsCard(card.key)}
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
<span className="text-[11px] text-gray-500 font-medium">
({faNum(card.milestones.length)} مرحله)
</span>
</div>
{!hasReceipts && tsCards.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف گزینه"
onClick={() => removeTsCard(card.key)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="flex flex-col gap-2 mb-3">
{card.milestones.map((ms, i) => (
<MilestoneRow
key={i}
milestone={ms}
disabled={hasReceipts}
onChange={(nextMs) =>
updateTs(card.key, {
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
}
/> />
))} ))}
</div> </div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="w-full sm:w-auto text-xs text-gray-700"
onClick={() =>
updateTs(card.key, {
milestones: [...card.milestones, { when_iso: null, amount: null }],
})
}
>
<Plus className="h-3.5 w-3.5 me-1" />
افزودن مرحله پرداخت
</Button>
)}
</div>
))}
</div>
</div> </div>
)} )}
@@ -891,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)} loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/> />
</div> </div>
{/* پیش‌فاکتورها — در همه‌ی مراحل چرخه‌ی پیشنهاد قابل افزودن/ویرایش است */}
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">پیش‌فاکتورها</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
</div> </div>
)} )}
</div> </div>
{/* فوتر سفید، محکم و چسبان */} {/* فوتر */}
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2"> <DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
{q.status === "offered" ? ( {q.status === "offered" ? (
<Button <Button
@@ -924,7 +923,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* مودال تایید حذف سفید */} {/* مودال تایید حذف */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}> <Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900"> <DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader> <DialogHeader>
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
onAccept: (id: string) => void; onAccept: (id: string) => void;
onEdit: (id: string) => void; onEdit: (id: string) => void;
canWrite?: boolean;
} }
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل) // دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
@@ -20,6 +21,7 @@ export function LedgerTable({
onToggleExpand, onToggleExpand,
onAccept, onAccept,
onEdit, onEdit,
canWrite = true,
}: LedgerTableProps) { }: LedgerTableProps) {
if (!quotes.length) { if (!quotes.length) {
return ( return (
@@ -40,6 +42,7 @@ export function LedgerTable({
onToggleExpand={() => onToggleExpand(q.id)} onToggleExpand={() => onToggleExpand(q.id)}
onAccept={() => onAccept(q.id)} onAccept={() => onAccept(q.id)}
onEdit={() => onEdit(q.id)} onEdit={() => onEdit(q.id)}
canWrite={canWrite}
/> />
))} ))}
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]"> <div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
+87 -33
View File
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
onToggleExpand: () => void; onToggleExpand: () => void;
onAccept: () => void; onAccept: () => void;
onEdit: () => void; onEdit: () => void;
canWrite?: boolean;
} }
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
@@ -35,8 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
); );
} }
// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id); const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q); const expired = isExpired(q);
const total = quoteTotal(q); const total = quoteTotal(q);
@@ -47,14 +47,19 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
// ستون / دکمه پذیرش // ستون / دکمه پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
if (q.status === "offered") { if (q.status === "offered") {
acceptedCell = ( acceptedCell = canWrite ? (
<button <button
onClick={onAccept} 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="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"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
<span>پذیرش</span> <span>پذیرش</span>
</button> </button>
) : (
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
); );
} else if (q.accepted_date) { } else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>; acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
@@ -134,53 +139,83 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
expanded && "border-accent/60 ring-1 ring-accent/20", expanded && "border-accent/60 ring-1 ring-accent/20",
)} )}
> >
{/* سربرگ کارت */} {/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */}
<div className="flex 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"> <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"> <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)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5"> {/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */}
<div className="flex items-center">
<Link <Link
to={`/suppliers/${q.supplier_id}`} to={`/suppliers/${q.supplier_id}`}
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]" 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} {sup.name}
</Link> </Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */} </div>
<span
className={cn( {/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4", <div className="mt-0.5 flex min-w-0 items-center gap-1.5">
q.is_official {sup.location && (
? "bg-green-bg text-green" <span className="truncate text-[10px] text-muted sm:text-[11px]">
: "bg-slate-bg text-muted", {sup.location}
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span> </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>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{fulfillmentBadge ?? ( {fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs"> <Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]} {STATUS_LABEL[q.status]}
</Badge> </Badge>
)} )}
{/* دکمه ویرایش */}
{canWrite && (
<button <button
className="chip h-7 px-2 sm:h-auto sm:px-2.5" type="button"
onClick={onEdit} 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();
}}
title="ویرایش پیشنهاد" title="ویرایش پیشنهاد"
aria-label="ویرایش" aria-label="ویرایش"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span> <span className="hidden sm:inline">ویرایش</span>
</button> </button>
)}
{/* دکمه فلش اکسپند در هدر */}
<button <button
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5" type="button"
onClick={onToggleExpand} className={cn(
title={expanded ? "جمع کردن" : "نمایش جزئیات"} "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} aria-expanded={expanded}
> >
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} /> <ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -188,9 +223,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</div> </div>
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */} {/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"> <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"> <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]"> <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" /> <Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
@@ -200,7 +238,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</InfoCell> </InfoCell>
{/* ۲. اقلام — نمایش عمودی چندخطی */} {/* ۲. اقلام */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1"> <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]"> <div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? ( {activeItems.length > 0 ? (
@@ -221,6 +259,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Link <Link
key={w.unit} key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(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]" className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`} title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
> >
@@ -230,6 +269,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))} ))}
</InfoCell> </InfoCell>
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1"> <InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? ( {pc ? (
<div> <div>
@@ -286,8 +326,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</span> </span>
</div> </div>
{/* جزئیات تکمیلی بازشونده */} {/* دکمه تمام‌عرض توضیحات بیشتر */}
{expanded && <QuoteDetail store={store} q={q} />} <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 && (
<div onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} />
</div>
)}
</div> </div>
); );
} }
@@ -76,8 +76,9 @@ export function ChangeInventoryPage() {
} }
}, [params]); }, [params]);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isAdmin = user?.role === "admin";
const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const canModify = isSupervisor || isAdmin;
const warehouses = data.warehouses; const warehouses = data.warehouses;
const whOpts = useMemo( const whOpts = useMemo(
@@ -401,7 +402,7 @@ export function ChangeInventoryPage() {
const meta = FORM_TYPES.find((f) => f.t === type)!; const meta = FORM_TYPES.find((f) => f.t === type)!;
if (isAdmin) { if (!canModify) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
@@ -409,22 +410,12 @@ export function ChangeInventoryPage() {
<div className="rounded-xl border border-line bg-panel p-8 text-center"> <div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div> <div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقط‌خواندنی</h3> <h3 className="mt-2 text-[15px] font-bold">دسترسی فقط‌خواندنی</h3>
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p> <p className="mt-1 text-[12.5px] text-muted">امکان تغییر موجودی انبار منحصراً در اختیار سرپرست پروژه و ادمین است.</p>
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
); );
} }
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const isCount = type === "STOCK_COUNT"; const isCount = type === "STOCK_COUNT";
const isTransfer = type === "TRANSFER"; const isTransfer = type === "TRANSFER";
+15 -13
View File
@@ -51,8 +51,10 @@ export function InventoryPage() {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData(); const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const isAdmin = user?.role === "admin";
const canModify = isSupervisor || isAdmin;
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -67,10 +69,10 @@ export function InventoryPage() {
const [expBrand, setExpBrand] = useState<string | null>(null); const [expBrand, setExpBrand] = useState<string | null>(null);
const visibleWarehouses = useMemo(() => { const visibleWarehouses = useMemo(() => {
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project); if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isAdmin) return warehouses; if (isManager) return warehouses;
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || "")); return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
}, [warehouses, isAdmin, filters.project, user?.project_id]); }, [warehouses, isManager, filters.project, user?.project_id]);
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]); const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
@@ -133,12 +135,12 @@ export function InventoryPage() {
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project)); const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) { if (!user) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
<PageLayout> <PageLayout>
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." /> <EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
</PageLayout> </PageLayout>
</div> </div>
); );
@@ -150,10 +152,10 @@ export function InventoryPage() {
title="سامانه مدیریت انبار" title="سامانه مدیریت انبار"
sub={inventorySubheader(user)} sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />} menu={<AppSwitcherMenu activeModule="warehouse" />}
actions={ actions={
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} /> <InventoryTopNav pathname={location.pathname} />
</div> </div>
} }
/> />
@@ -246,8 +248,8 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */} {/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && ( {showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5"> <div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* پروژه‌ها (مخصوص ادمین) */} {/* پروژه‌ها (مدیران و ادمین) */}
{isAdmin && lookup.projects.length > 0 && ( {isManager && lookup.projects.length > 0 && (
<div> <div>
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div> <div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -415,7 +417,7 @@ export function InventoryPage() {
</PageLayout> </PageLayout>
{/* دکمه شناور تغییر موجودی */} {/* دکمه شناور تغییر موجودی */}
{isPM && ( {canModify && (
<Link <Link
to="/inventory/change" to="/inventory/change"
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8" className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
@@ -1,26 +1,32 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها // ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها
export function InventoryTopNav({ export function InventoryTopNav({
pathname, pathname,
variant = "pm",
}: { }: {
pathname: string; pathname: string;
variant?: "pm" | "admin";
}) { }) {
const items = const user = useAuthStore((s) => s.user);
variant === "admin" const isAdmin = user?.role === "admin";
? [ const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, const items: { label: string; to: string; active: boolean }[] = [];
{ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") }, // سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده می‌کند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمی‌شود
] if (isAdmin) {
: [ items.push({
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }, label: "تغییر موجودی",
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }, to: "/inventory/change",
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, active: pathname.startsWith("/inventory/change"),
]; });
}
if (isManager) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
return ( return (
<> <>
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks";
import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
type Role = "pm" | "procurement_manager" | "admin" | null;
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
};
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
export function OrganizationPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [syncing, setSyncing] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [newWh, setNewWh] = useState<Record<string, string>>({});
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
// پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند.
const canChangeRoles = user?.role === "admin";
const loadUsers = useCallback(async () => {
try {
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
const syncUsers = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0
? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
// پروژه‌های متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تک‌پروژه‌ای
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
// همگام‌سازی چند-انتخابیِ پروژه‌ها: افزودن/حذف هر اختلاف با یک PATCH
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین می‌شود)
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) => data.lookup.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();
await data.refresh();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers(); // وضعیت واقعی سرور را برگردان
} finally {
setBusyId(null);
}
};
// ---------- actions: warehouses ----------
const addWarehouse = async (projectId: string) => {
const name = (newWh[projectId] || "").trim();
if (!name) return toast.error("نام انبار الزامی است");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
setNewWh((s) => ({ ...s, [projectId]: "" }));
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
toast.success(`انبار «${name}» اضافه شد ✓`);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
}
};
// انبار برای همیشه به پروژه‌ی والدش تعلق دارد — انتقال به پروژه‌ی دیگر وجود ندارد؛
// انبار فقط با حذف و ساخت دوباره زیر پروژه‌ی موردنظر جابه‌جا می‌شود.
const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
await apiV26.warehouses.delete(id);
toast.success("انبار حذف شد");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
}
};
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
const pmOf = (projectId: string) =>
users.find(
(u) =>
u.role === "pm" &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
) || null;
return (
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران از Casdoor همگام می‌شوند و نقش‌ها اینجا (فقط توسط ادمین) تعیین می‌شوند؛
مدیر تدارکات فقط پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند. پروژه‌ها از سیستم تدارکات می‌آیند و
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
</div>
{/* ---------- کاربران و مدیران پروژه ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
<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"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 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">
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) به‌صورت خودکار ساخته می‌شوند؛
یا دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</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) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-1.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
</td>
<td className="py-1.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="admin">ادمین</option>
</select>
) : (
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
</span>
)}
{!canChangeRoles && u.role && (
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
)}
</td>
<td className="min-w-[240px] py-1.5 pe-3 align-top">
{u.role === "pm" ? (
<ProjectMultiSelect
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(ids) => void syncUserProjects(u, ids)}
disabled={busyId === u.id}
/>
) : (
<span className="text-[11.5px] text-muted">—</span>
)}
</td>
<td className="py-1.5">
{u.role === "pm" && u.project_id && (
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-1 text-[11px] text-muted">
«بدون نقش» = حذف مدیر پروژه (از همه‌ی پروژه‌ها هم جدا می‌شود). هر مدیر پروژه می‌تواند به
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا می‌کند و اتصال به یک پروژه، مدیرِ
قبلیِ آن پروژه را جابه‌جا می‌کند.
</div>
</div>
)}
</div>
{/* ---------- پروژه‌ها و انبارها ---------- */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.lookup.projects.map((p) => {
const whs = data.warehouses.filter((w) => w.project_id === p.id);
const pm = pmOf(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">
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
{faNum(whs.length)} انبار
</span>
</div>
<div className="mb-3">
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
{pm ? (
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
</div>
) : (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
</div>
)}
</div>
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
{!whs.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
) : (
<div className="mb-2 flex flex-col gap-1.5">
{whs.map((w) => (
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<div className="min-w-0 flex-1">
<b>{w.name}</b>
<small className="block truncate text-muted">{w.description || ""}</small>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
title="داشبورد این انبار"
>
📊 داشبورد
</button>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
placeholder="نام انبار جدید"
value={newWh[p.id] || ""}
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<input
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
placeholder="توضیحات (اختیاری)"
value={newWhDesc[p.id] || ""}
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
+ افزودن انبار
</button>
</div>
</div>
);
})}
</div>
</PageLayout>
</div>
);
}
+25 -23
View File
@@ -9,52 +9,41 @@ import { useInventoryData, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد) // انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
export function WarehousesPage() { export function WarehousesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const data = useInventoryData(); const data = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isPM = user?.role === "pm";
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState(""); const [newDesc, setNewDesc] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState(""); const [editDesc, setEditDesc] = useState("");
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازمان‌دهی» if (!canManage) {
if (isAdmin) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout> <PageLayout>
<div className="rounded-xl border border-line bg-panel p-8 text-center"> <div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div> <div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی ندارد</h3> <h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
<p className="mt-1 text-[12.5px] text-muted"> <p className="mt-1 text-[12.5px] text-muted">
بخش «انبارها» مخصوص مدیران پروژه است. مدیریت انبارها از بخش «سازمان‌دهی» انجام می‌شود. مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد.
</p> </p>
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
); );
} }
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const addWarehouse = async () => { const addWarehouse = async () => {
const name = newName.trim(); const name = newName.trim();
if (!name) return toast.error("نام انبار الزامی است"); if (!name) return toast.error("نام انبار الزامی است");
const projectId = user?.project_id; const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id;
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید"); if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
try { try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null }); await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
@@ -99,13 +88,26 @@ export function WarehousesPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
{isPM && ( {canManage && (
<div className="rounded-xl border border-line bg-panel p-3"> <div className="rounded-xl border border-line bg-panel p-3">
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3> <h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap items-center gap-2">
{data.lookup.projects.length > 1 && (
<select
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
onChange={(e) => setSelectedProjectId(e.target.value)}
>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>
پروژه: {p.name}
</option>
))}
</select>
)}
<input <input
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm" className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام انبار (مثلاً انبار موقت)" placeholder="نام انبار (مثلاً انبار مرکزی)"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
/> />
@@ -148,7 +150,7 @@ export function WarehousesPage() {
> >
📊 مشاهده داشبورد انبار 📊 مشاهده داشبورد انبار
</button> </button>
{isPM && ( {canManage && (
<> <>
<button <button
className="rounded-lg border border-line px-3 py-1 text-[11.5px]" className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
@@ -162,7 +164,7 @@ export function WarehousesPage() {
</> </>
)} )}
</div> </div>
{editId === w.id && isPM && ( {editId === w.id && canManage && (
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}> <div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
<input <input
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm" className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
+19 -27
View File
@@ -10,20 +10,22 @@ export function useLogout() {
} }
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین", admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
}; };
// زیرعنوان پویای سربرگ پنل انبار: // زیرعنوان پویای سربرگ پنل انبار:
// [نام کاربر] | [نقش] – [پروژه / همه پروژه‌ها] // [نام کاربر] | [نقش] – [پروژه / همه پروژه‌ها]
// مدیر پروژه → نام پروژه‌ی خودش؛ مدیر تدارکات/ادمین → «همه پروژه‌ها». // سرپرست پروژه → نام پروژه‌ی خودش؛ مدیران/ادمین → «همه پروژه‌ها».
export function inventorySubheader(user: { display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string { export function inventorySubheader(user: { username?: string; casdoor_id?: string; display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
if (!user) return "در حال بارگذاری…"; if (!user) return "در حال بارگذاری…";
const name = user.display_name || `${user.first_name} ${user.last_name}`.trim() || "کاربر"; const name = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const uname = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش"); const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژه‌ها"; const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${name} | ${role} – ${proj}`; return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
} }
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و // منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
return items; return items;
} }
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب» // منوی همبرگری سرپرست پروژه در پنل انبار
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمه‌ی خروجِ تکی هم حذف شده).
export function pmMenuItems(onLogout?: () => void): MenuItem[] { export function pmMenuItems(onLogout?: () => void): MenuItem[] {
const items: MenuItem[] = []; const items: MenuItem[] = [];
if (onLogout) { if (onLogout) {
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
return items; return items;
} }
// آیتم‌های منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج // آیتم‌های بخش‌های داخلی انبار
//
// دسترسی‌ها (v2.6):
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازمان‌دهی —
// بدون «تغییر موجودی» و بدون «انبارها».
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
// بدون «سازمان‌دهی».
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] { export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
const isAdmin = role === "admin" || role === "procurement_manager"; const isAdmin = role === "admin";
const isPM = role === "pm"; const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
const isSupervisor = role === "supervisor" || isAdmin;
const items: MenuItem[] = [ const items: MenuItem[] = [
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" }, { label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
]; ];
if (isPM) { // فقط سرپرست پروژه و ادمین می‌توانند تغییر موجودی بدهند
if (isSupervisor) {
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") }); items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
} }
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران
if (isManager) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }); items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
if (isPM) {
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }); items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
}
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }); items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
if (isAdmin) {
items.push({ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
}
if (isAdmin) {
items.push({ divider: true });
items.push({ label: "بازگشت به دفتر", to: "/" });
} }
if (onLogout) { if (onLogout) {
items.push({ divider: true }); items.push({ divider: true });
@@ -26,8 +26,8 @@ export interface InventoryData {
export function useInventoryData(): InventoryData { export function useInventoryData(): InventoryData {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const projectId = isPM ? user.project_id || undefined : undefined; const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [inventory, setInventory] = useState<InventoryRow[]>([]); const [inventory, setInventory] = useState<InventoryRow[]>([]);
@@ -0,0 +1,291 @@
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 { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
type Role = "pm" | "procurement_manager" | "admin" | "supervisor" | null;
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان
export function OrganizationPage() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [syncing, setSyncing] = useState(false);
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 loadUsers = useCallback(async () => {
try {
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
const syncUsers = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0
? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین، مدیر تدارکات و مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
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 supervisorsOf = (projectId: string) =>
users.filter(
(u) =>
(u.role === "supervisor" || u.role === "pm") &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
);
return (
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
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 تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند.
مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند.
</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>
<p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه
</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"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 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">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</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 pe-3">پروژه‌های تخصیص‌یافته (سرپرست پروژه)</th>
<th className="py-2">وضعیت</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
</td>
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
{u.username || "—"}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
<td className="py-2.5 pe-3">
{u.role === "supervisor" || u.role === "pm" ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</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>
</PageLayout>
</div>
);
}
+161 -56
View File
@@ -1,11 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react"; import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban, MapPin, UserCircle2, Trash2, Plus, X } from "lucide-react";
import type { Project, Quote, Store } from "../../lib/types"; import type { Project, Quote, Store } from "../../lib/types";
import { nextId } from "../../lib/api";
import { import {
useAddProject, useAddProject,
useDeleteProject,
useMaterials, useMaterials,
useProjects, useProjects,
useQuotes, useQuotes,
@@ -25,6 +25,7 @@ import { faNum, fmtDate } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
function projectQuotes(quotes: Quote[], name: string): Quote[] { function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter( return quotes.filter(
@@ -39,23 +40,14 @@ function projectQuotes(quotes: Quote[], name: string): Quote[] {
); );
} }
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده،
// برای این پروژه همچنان «پیشنهاد شده» است.
function acceptedForProject(q: Quote, name: string): boolean { function acceptedForProject(q: Quote, name: string): boolean {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name); return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
} }
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean { function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project); return (q.line_items || []).some((li) => !!li.target_project);
} }
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] { function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected); const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name); const targeted = items.filter((li) => li.target_project?.name === name);
@@ -73,6 +65,97 @@ function daysSince(iso: string | null): number | null {
return Math.floor((Date.now() - d.getTime()) / 86400000); return Math.floor((Date.now() - d.getTime()) / 86400000);
} }
// ----- مودال افزودن پروژه -----
function AddProjectModal({
open,
onClose,
onSubmit,
loading,
}: {
open: boolean;
onClose: () => void;
onSubmit: (p: Omit<Project, "id">) => void;
loading: boolean;
}) {
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [projectManager, setProjectManager] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState("");
const inputCls =
"w-full rounded-lg border border-line bg-surface px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40";
const reset = () => {
setName(""); setLocation(""); setProjectManager(""); setDescription(""); setError("");
};
const handleClose = () => { reset(); onClose(); };
const handleSubmit = () => {
if (!name.trim()) { setError("نام پروژه الزامی است"); return; }
setError("");
onSubmit({ name: name.trim(), location: location.trim(), project_manager: projectManager.trim(), description: description.trim() });
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={handleClose}>
<div
className="w-full max-w-lg rounded-2xl bg-panel shadow-2xl border border-line p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold">افزودن پروژه جدید</h2>
<button type="button" onClick={handleClose} className="text-muted hover:text-on-surface transition-colors">
<X size={18} />
</button>
</div>
<div className="space-y-3">
{error && <p className="rounded-lg bg-red-50 border border-red-200 text-red-700 px-3 py-2 text-xs">{error}</p>}
<div>
<label className="mb-1 block text-xs font-semibold text-muted">نام پروژه *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
placeholder="مثلاً: برج مسکونی آسمان"
className={inputCls}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-muted">محل پروژه</label>
<input value={location} onChange={(e) => setLocation(e.target.value)} placeholder="شهر / منطقه" className={inputCls} />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-muted">مدیر / سرپرست پروژه</label>
<input value={projectManager} onChange={(e) => setProjectManager(e.target.value)} placeholder="نام سرپرست" className={inputCls} />
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-muted">توضیحات</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="توضیحات تکمیلی پروژه..." className={`${inputCls} resize-none`} />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={handleClose} className="h-9 rounded-lg border border-line px-4 text-sm text-muted hover:bg-surface transition-colors">انصراف</button>
<button type="button" disabled={loading} onClick={handleSubmit} className="h-9 rounded-lg bg-accent px-4 text-sm font-semibold text-white hover:bg-accent/90 disabled:opacity-50 transition-colors">
{loading ? "در حال ذخیره..." : "افزودن پروژه"}
</button>
</div>
</div>
</div>
</div>
);
}
// صفحه‌ی پروژه‌ها — لیست + پنل جزئیات ۳ ستونه // صفحه‌ی پروژه‌ها — لیست + پنل جزئیات ۳ ستونه
export function ProjectsPage() { export function ProjectsPage() {
useStoreData(); useStoreData();
@@ -82,54 +165,53 @@ export function ProjectsPage() {
const materials = useMaterials(); const materials = useMaterials();
const { standardConversions } = useUnits(); const { standardConversions } = useUnits();
const addProject = useAddProject(); const addProject = useAddProject();
const deleteProject = useDeleteProject();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics }; const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null); const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState(""); const [showAddModal, setShowAddModal] = useState(false);
const addProj = () => { const handleAddProject = async (data: Omit<Project, "id">) => {
const name = newName.trim(); const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
if (!name) return; if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
const dup = projects.some((p) => normalize(p.name) === normalize(name)); try {
if (dup) { await addProject.mutateAsync({ id: "", ...data });
toast.error("این پروژه قبلاً ثبت شده است."); setShowAddModal(false);
return; setOpenProject(data.name);
toast.success("پروژه با موفقیت اضافه شد.");
} catch {
toast.error("خطا در ثبت پروژه");
}
};
const handleDeleteProject = async (p: Project, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`آیا از حذف پروژه «${p.name}» مطمئن هستید؟`)) return;
try {
await deleteProject.mutateAsync(p.id);
toast.success("پروژه حذف شد.");
if (openProject === p.name) setOpenProject(null);
} catch (err: any) {
toast.error(err?.message || "این پروژه دارای پیشنهاد است و قابل حذف نیست");
} }
const p: Project = { id: nextId("p", projects), name };
void addProject.mutateAsync(p);
setNewName("");
setOpenProject(name);
toast.success("پروژه افزوده شد.");
}; };
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="پروژه‌ها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" /> <Header title="پروژه‌ها" sub="مدیریت پروژه‌های مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-4">
<div className="add-panel"> <div className="add-panel flex items-center justify-between">
<h4>+ افزودن پروژه‌ی جدید</h4> <h4>پروژه‌های سامانه</h4>
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2"> {canManage && (
<div className="field"> <Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
<label>نام پروژه *</label> <Plus size={15} />
<input افزودن پروژه
type="text" </Button>
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" )}
placeholder="مثلاً برج آیتا - فاز ۳"
autoComplete="off"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProj();
}
}}
/>
</div>
<Button onClick={addProj}>افزودن پروژه</Button>
</div>
</div> </div>
{!projects.length && !quotes.length ? ( {!projects.length && !quotes.length ? (
@@ -141,16 +223,37 @@ export function ProjectsPage() {
const open = openProject === p.name; const open = openProject === p.name;
return ( return (
<div key={p.id} className="flex flex-col gap-1.5"> <div key={p.id} className="flex flex-col gap-1.5">
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
<button <button
type="button" type="button"
className={`proj-item ${open ? "open" : ""}`} className="flex flex-1 items-center gap-2 min-w-0 text-right"
onClick={() => setOpenProject(open ? null : p.name)} onClick={() => setOpenProject(open ? null : p.name)}
> >
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span> <span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium">{p.name}</span> <span className="font-medium truncate">{p.name}</span>
<span className="proj-count">{faNum(count)} پیشنهاد</span> {p.location && (
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
<MapPin size={10} />{p.location}
</span>
)}
{p.project_manager && (
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
<UserCircle2 size={10} />{p.project_manager}
</span>
)}
<span className="proj-count ms-auto shrink-0">{faNum(count)} پیشنهاد</span>
</button> </button>
{/* جزئیات دقیقاً زیر همان پروژه باز می‌شود */} {canManage && (
<button
type="button"
onClick={(e) => handleDeleteProject(p, e)}
title="حذف پروژه"
className="text-muted hover:text-red-600 transition-colors p-1 rounded shrink-0"
>
<Trash2 size={13} />
</button>
)}
</div>
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>} {open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
</div> </div>
); );
@@ -158,6 +261,13 @@ export function ProjectsPage() {
</div> </div>
)} )}
</PageLayout> </PageLayout>
<AddProjectModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddProject}
loading={addProject.isPending}
/>
</div> </div>
); );
} }
@@ -166,7 +276,6 @@ export function ProjectsPage() {
function renderProjectDetail(store: Store, name: string): React.ReactElement { function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name); const quotes = projectQuotes(store.quotes, name);
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
const itemsTotal = (q: Quote) => const itemsTotal = (q: Quote) =>
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0); itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0); const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
@@ -196,10 +305,6 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
); );
}; };
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes const offered = quotes
.filter( .filter(
(q) => (q) =>
@@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox";
import { SupplierCard, suppliersOfCategory } from "./supplierUtils"; import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { materialLabel } from "../../lib/quoteUtils"; import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
export function CategoryPage() { export function CategoryPage() {
@@ -26,6 +27,8 @@ export function CategoryPage() {
const addSupplier = useAddSupplier(); const addSupplier = useAddSupplier();
const removeSupplier = useRemoveSupplier(); const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]); const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
@@ -121,7 +124,7 @@ export function CategoryPage() {
key={s.id} key={s.id}
s={s} s={s}
topic={cat} topic={cat}
deletable deletable={canManage}
onDelete={removeSup} onDelete={removeSup}
materials={supMats.get(s.id) || []} materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)} avgLeadTime={supLeadTimes.get(s.id)}
@@ -130,6 +133,7 @@ export function CategoryPage() {
</div> </div>
)} )}
{canManage && (
<div className="add-panel"> <div className="add-panel">
<h4> <h4>
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید <Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید
@@ -182,6 +186,7 @@ export function CategoryPage() {
ذخیره تأمین‌کننده ذخیره تأمین‌کننده
</Button> </Button>
</div> </div>
)}
</PageLayout> </PageLayout>
</div> </div>
); );
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react"; import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks"; import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -13,6 +14,16 @@ import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog"; import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory"; import { PurchaseHistory } from "./PurchaseHistory";
import { useAuthStore } from "../../store/auth";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده // صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() { export function SupplierDetailPage() {
@@ -22,6 +33,8 @@ export function SupplierDetailPage() {
const quotes = useQuotes(); const quotes = useQuotes();
const materials = useMaterials(); const materials = useMaterials();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]); const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
const [pendingRemove, setPendingRemove] = useState<string | null>(null); const [pendingRemove, setPendingRemove] = useState<string | null>(null);
@@ -29,6 +42,19 @@ export function SupplierDetailPage() {
const [linked, setLinked] = useState<string[]>([]); const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// محاسبه میانگین زمان تحویل این تأمین‌کننده بر اساس خریدهای تحویل‌شده
const avgLeadTime = useMemo(() => {
if (!s?.id) return null;
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
}, [quotes, s?.id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
let alive = true; let alive = true;
@@ -68,7 +94,6 @@ export function SupplierDetailPage() {
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`; const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => { const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return; if (!id || busy) return;
setBusy(true); setBusy(true);
void (async () => { void (async () => {
@@ -116,22 +141,53 @@ export function SupplierDetailPage() {
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" /> <Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout> <PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div> <div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge> <Badge variant="outline">{topicName}</Badge>
</div> </div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]"> {avgLeadTime != null && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
<Clock className="h-3.5 w-3.5 text-emerald-400" />
<span>{avgLeadTime} روز</span>
</span>
)}
</div>
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
<span> <span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"} <MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span> </span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"} {/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
</span> <div className="flex items-center">
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
<b className="me-1">تلفن:</b>
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
dir="ltr"
title="تماس مستقیم با تأمین‌کننده"
>
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span className="text-muted">—</span>
)}
</div>
<span> <span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"} <User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span> </span>
<span>
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
</span>
</div> </div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
{canManage && (
<Link <Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`} to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
@@ -139,6 +195,7 @@ export function SupplierDetailPage() {
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
پیشنهاد جدید پیشنهاد جدید
</Link> </Link>
)}
{firstCat ? ( {firstCat ? (
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline"> <Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
‹ بازگشت به دسته ‹ بازگشت به دسته
@@ -151,7 +208,7 @@ export function SupplierDetailPage() {
</div> </div>
</div> </div>
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */} {/* متریال‌های این تأمین‌کننده */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold"> <h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده <Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
@@ -164,19 +221,23 @@ export function SupplierDetailPage() {
{linked.map((c) => ( {linked.map((c) => (
<span key={c} className="spec-chip"> <span key={c} className="spec-chip">
{c} {c}
{canManage && (
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}> <button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
)}
</span> </span>
))} ))}
</div> </div>
)} )}
{canManage && (
<MultiSelectCombobox <MultiSelectCombobox
values={[]} values={[]}
onChange={(cats) => saveCategories(cats)} onChange={(cats) => saveCategories(cats)}
options={materialOptions} options={materialOptions}
placeholder="افزودن متریال..." placeholder="افزودن متریال..."
/> />
)}
{purchased.length > 0 && ( {purchased.length > 0 && (
<div className="mt-3 border-t border-line pt-2"> <div className="mt-3 border-t border-line pt-2">
<div className="mb-1 text-[11px] font-semibold text-muted">متریال‌های خریداری‌شده (از پیشنهادها):</div> <div className="mb-1 text-[11px] font-semibold text-muted">متریال‌های خریداری‌شده (از پیشنهادها):</div>
@@ -191,7 +252,7 @@ export function SupplierDetailPage() {
)} )}
</div> </div>
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */} {/* سابقه خرید از این تأمین‌کننده */}
<div className="mt-4 max-w-4xl"> <div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} /> <PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div> </div>
+33 -2
View File
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters"; import { fmtNum } from "../../lib/formatters";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
return suppliers.filter( return suppliers.filter(
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)), (s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
@@ -29,7 +38,11 @@ export function SupplierCard({
return ( return (
<div className="sup-card"> <div className="sup-card">
<div className="sc-head flex items-start justify-between gap-2"> <div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug"> {/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */}
<Link
to={`/suppliers/${s.id}`}
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
>
{s.name} {s.name}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
@@ -49,17 +62,34 @@ export function SupplierCard({
)} )}
</div> </div>
</div> </div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"} <MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
</span> </span>
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
title="تماس مستقیم با تأمین‌کننده"
dir="ltr"
>
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span> <span>
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"} <Phone className="me-1 inline h-3 w-3 text-muted" /> —
</span> </span>
)}
<span> <span>
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"} <User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
</span> </span>
</div> </div>
{materials.length > 0 && ( {materials.length > 0 && (
<div className="mt-2 border-t border-line pt-1.5"> <div className="mt-2 border-t border-line pt-1.5">
<div className="mb-1 flex flex-wrap gap-1"> <div className="mb-1 flex flex-wrap gap-1">
@@ -71,6 +101,7 @@ export function SupplierCard({
</div> </div>
</div> </div>
)} )}
<div className="sc-actions"> <div className="sc-actions">
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline"> <Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
+111 -55
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus } from "lucide-react";
import type { Material } from "../../lib/types"; import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks"; import { useMaterials } from "../../hooks";
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
import { faNum } from "../../lib/formatters";
export interface ItemDraft { export interface ItemDraft {
tempId: string; tempId: string;
@@ -51,7 +53,6 @@ export function materialOptions(
apiBrands: string[] = [], apiBrands: string[] = [],
apiSpecs: string[] = [], apiSpecs: string[] = [],
): string[] { ): string[] {
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials; let list = materials;
const cat = item.cat.trim(); const cat = item.cat.trim();
@@ -67,21 +68,17 @@ export function materialOptions(
}; };
if (field === "brand") { if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و متریال
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b)); apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa")); return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
} }
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ
// هم‌نام پیشنهاد می‌شوند.
if (cat) if (cat)
list = list.filter( list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat), (m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
); );
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
const base = new Set<string>(); const base = new Set<string>();
apiSpecs.forEach((s) => s && base.add(s)); apiSpecs.forEach((s) => s && base.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s))); materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
@@ -90,7 +87,7 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa")); return [...base].sort((a, b) => a.localeCompare(b, "fa"));
} }
// مرحله ۲ — جدول پویای اقلام سفارش // مرحله ۲ — مدیریت اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials(); const materials = useMaterials();
@@ -167,46 +164,83 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return; if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs }; rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
} }
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
// همان هویتِ متریال انجام می‌شود، نه دسته.
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs); const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
if (dup) return; if (dup) return;
onRemember(rec); onRemember(rec);
}; };
return ( return (
<div> <div className="space-y-4">
<h3 className="mb-1 text-[15px] font-bold"> {/* هدر */}
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span> <div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
۲
</span>
<h3 className="text-sm sm:text-base font-bold text-ink">
اقلام سفارش اقلام سفارش
</h3> </h3>
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} /> </div>
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
<div className="item-row wizard-item-row item-row-head mb-1 pe-7"> {faNum(items.length)} قلم
<span>متریال</span> </span>
<span>برند</span> </div>
<span>مشخصه</span> <p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
<span>تعداد</span> مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینه‌های جدید با تایپ ذخیره می‌شوند.
<span>واحد</span> </p>
<span>قیمت واحد</span>
<span>قیمت کل</span>
</div> </div>
<div data-field="line_items" className="flex flex-col gap-2"> {/* لیست کارت‌های اقلام */}
{items.map((it) => ( <div data-field="line_items" className="flex flex-col gap-3">
<div key={it.tempId} className="item-row wizard-item-row items-center"> {items.map((it, idx) => (
<div
key={it.tempId}
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
>
{/* سربرگ قلم */}
<div className="flex items-center justify-between border-b border-line pb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-ink truncate">
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span>
</div>
{items.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
<Combobox <Combobox
value={it.cat} value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })} onChange={(x) => patch(it.tempId, { cat: x.label })}
items={allMaterialNames} items={allMaterialNames}
allowAdd allowAdd
addSuffix="به عنوان متریال جدید" addSuffix="به عنوان متریال جدید"
placeholder="متریال" placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })} onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)} onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-[12px] !h-9" inputClassName="!text-xs !h-9 text-right"
className="min-w-0" className="min-w-0"
/> />
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
<Combobox <Combobox
value={it.brand} value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })} onChange={(x) => patch(it.tempId, { brand: x.label })}
@@ -216,34 +250,52 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="برند" placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })} onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)} onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-[12px] !h-9" inputClassName="!text-xs !h-9 text-right"
className="min-w-0" className="min-w-0"
/> />
</div>
</div>
{/* ردیف ۲: مشخصه فنی */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox <MultiSelectCombobox
values={it.specs} values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })} onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)} options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه" placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
className="min-w-0" className="min-w-0"
/> />
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
<NumberInput <NumberInput
value={it.qty} value={it.qty}
onChange={(qty) => { onChange={(qty) => {
patch(it.tempId, { patch(it.tempId, {
qty, qty,
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total, total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
}); });
}} }}
placeholder="تعداد" placeholder="مقدار"
showWords className="!text-right text-right"
/> />
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
<Combobox <Combobox
value={it.unit} value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })} onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))} items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd allowAdd
addSuffix="به عنوان واحد جدید" addSuffix="به عنوان واحد جدید"
placeholder="واحد" placeholder="واحد (شاخه، متر، کیلو...)"
onInputChange={(v) => patch(it.tempId, { unit: v })} onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => { onAdd={(v) => {
const name = v.trim(); const name = v.trim();
@@ -257,56 +309,60 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}) })
.catch(() => {}); .catch(() => {});
}} }}
inputClassName="!text-[12px] !h-9" inputClassName="!text-xs !h-9 text-right"
className="min-w-0" className="min-w-0"
/> />
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput <NumberInput
value={it.price} value={it.price}
onChange={(price) => { onChange={(price) => {
patch(it.tempId, { patch(it.tempId, {
price, price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total, total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
}); });
}} }}
placeholder="قیمت واحد" placeholder="قیمت هر واحد"
showWords showWords
maxDecimals={2} className="!text-right text-right"
/> />
</div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
value={it.total} value={it.total}
onChange={(total) => { onChange={(total) => {
patch(it.tempId, { patch(it.tempId, {
total, total,
price: price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price, total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
}); });
}} }}
placeholder="قیمت کل" placeholder="قیمت کل قلم"
showWords showWords
className="!text-right text-right"
/> />
</div>
{/* دکمه حذف مدرن با آیکون سطل زباله */} </div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div> </div>
))} ))}
</div> </div>
{/* دکمه افزودن قلم با استایل برجسته و خط‌چین */} {/* دکمه افزودن قلم جدید */}
<button <button
type="button" type="button"
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150" className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
onClick={add} onClick={add}
> >
<span className="text-base font-bold leading-none">+</span> <Plus className="h-4 w-4" />
<span>افزودن قلم جدید</span> <span>افزودن قلم جدید</span>
</button> </button>
</div> </div>
+32 -14
View File
@@ -9,40 +9,58 @@ export interface StepperProps {
onGoto: (step: number) => void; onGoto: (step: number) => void;
} }
const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"]; const STEPS = [
{ id: 1, label: "تأمین‌کننده" },
{ id: 2, label: "اقلام" },
{ id: 3, label: "شرایط پرداخت" },
{ id: 4, label: "پروژه" },
{ id: 5, label: "بازبینی و ثبت" },
];
// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است // نوار ۵ مرحله‌ای ویزارد — واکنش‌گرا و ضدشکستگی در موبایل
// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی
export function Stepper({ current, completedSteps, onGoto }: StepperProps) { export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
return ( return (
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm"> <div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
{STEPS.map((label, i) => { {STEPS.map(({ id: step, label }) => {
const step = i + 1;
const isCurrent = step === current; const isCurrent = step === current;
const isComplete = completedSteps.has(step); const isComplete = completedSteps.has(step);
return ( return (
<button <button
key={step} key={step}
type="button" type="button"
className={cn( className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors", "flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
// در موبایل مرحله جاری فضا می‌گیرد و بقیه جمع‌وجور می‌مانند تا سطر نشکند
isCurrent isCurrent
? "bg-[#2563eb] font-bold text-white" ? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
: isComplete : "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
? "bg-green-bg font-medium text-green" isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
: "hover:bg-slate-bg",
)} )}
onClick={() => onGoto(step)} onClick={() => onGoto(step)}
> >
<span <span
className={cn( className={cn(
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold", "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted", isCurrent
? "bg-white/20 text-white"
: isComplete
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
: "bg-muted/15 text-muted",
)} )}
> >
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)} {isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
</span> </span>
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نام‌ها نمایش دارند */}
<span
className={cn(
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
!isCurrent && "hidden md:inline",
)}
>
{label} {label}
</span>
</button> </button>
); );
})} })}
+23 -39
View File
@@ -127,11 +127,9 @@ export function WizardPage() {
goToStep(currentStep + 1); goToStep(currentStep + 1);
}; };
const rememberMaterial = (_rec: MaterialRec) => { const rememberMaterial = (_rec: MaterialRec) => {};
// رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
};
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ---------- // ---------- پروژه‌ها ----------
const projectNames = useMemo(() => { const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name)); const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => { quotes.forEach((q) => {
@@ -159,7 +157,7 @@ export function WizardPage() {
}); });
}; };
// ---------- خلاصه‌ی مرحله ۵ ---------- // ---------- خلاصه مرحله ۵ ----------
const review = useMemo(() => { const review = useMemo(() => {
let grandTotal = 0; let grandTotal = 0;
items.forEach((it) => { items.forEach((it) => {
@@ -202,23 +200,14 @@ export function WizardPage() {
const combined = `${field} ${msg}`.toLowerCase(); const combined = `${field} ${msg}`.toLowerCase();
// ۱. تأمین‌کننده
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" }; if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
// ۲. اقلام / متریال / واحد / تعداد / قیمت
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) { if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
return { step: 2, field: field || "line_items" }; return { step: 2, field: field || "line_items" };
} }
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) { if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
return { step: 3, field: field || "termsheets" }; return { step: 3, field: field || "termsheets" };
} }
// ۴. پروژه
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" }; if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
// ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" }; if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" }; if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" }; if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" };
@@ -291,10 +280,6 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید"; const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName)); let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) { if (!material) {
const matched = matchMaterial( const matched = matchMaterial(
@@ -426,7 +411,7 @@ export function WizardPage() {
}; };
return ( return (
<div className="min-h-screen"> <div className="min-h-screen pb-10">
<Header <Header
title="افزودن پیشنهاد جدید" title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید" sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
@@ -434,10 +419,12 @@ export function WizardPage() {
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
/> />
<PageLayout> <PageLayout>
{/* نوار مراحل استپر */}
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} /> <Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
{currentStep === 1 && ( {currentStep === 1 && (
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step1TopicSupplier <Step1TopicSupplier
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
onTopicSelect={(t) => { onTopicSelect={(t) => {
@@ -461,12 +448,12 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} /> <Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div> </div>
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step3Termsheets <Step3Termsheets
cards={cards} cards={cards}
onCardsChange={setCards} onCardsChange={setCards}
@@ -477,7 +464,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step4Projects <Step4Projects
items={items} items={items}
projects={projectNames} projects={projectNames}
@@ -491,7 +478,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 5 && ( {currentStep === 5 && (
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step5Review <Step5Review
quoteDate={quoteDate} quoteDate={quoteDate}
onQuoteDate={setQuoteDate} onQuoteDate={setQuoteDate}
@@ -506,15 +493,14 @@ export function WizardPage() {
</div> </div>
)} )}
{/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */} {/* پاورقی کنترل مراحل */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm"> <div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
<div> <div>
{currentStep > 1 && ( {currentStep > 1 && (
<button <button
type="button" type="button"
onClick={() => goToStep(currentStep - 1)} onClick={() => goToStep(currentStep - 1)}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer" className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
<span>قبلی</span> <span>قبلی</span>
@@ -522,21 +508,19 @@ export function WizardPage() {
)} )}
</div> </div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */} <div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-3">
<Link <Link
to="/" to="/"
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline" className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
> >
انصراف انصراف
</Link> </Link>
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{currentStep < TOTAL_STEPS && ( {currentStep < TOTAL_STEPS && (
<button <button
type="button" type="button"
onClick={next} onClick={next}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer" className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
> >
<span>بعدی</span> <span>بعدی</span>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -547,7 +531,7 @@ export function WizardPage() {
<button <button
type="button" type="button"
onClick={() => void save()} onClick={() => void save()}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer" className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
> >
<span>ذخیره پیشنهاد</span> <span>ذخیره پیشنهاد</span>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
@@ -556,21 +540,21 @@ export function WizardPage() {
</div> </div>
</div> </div>
{/* دیالوگ ذخیره با فیلدهای خالی */} {/* دیالوگ فیلدهای خالی */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>فیلدهای خالی</DialogTitle> <DialogTitle>فیلدهای خالی</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="text-[13.5px] leading-7"> <div className="text-[13.5px] leading-7 text-gray-700">
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟ هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
</div> </div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
{emptyFields.map((f) => ( {emptyFields.map((f) => (
<button <button
key={f.step} key={f.step}
type="button" type="button"
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark" className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
onClick={() => { onClick={() => {
setConfirmOpen(false); setConfirmOpen(false);
goToStep(f.step); goToStep(f.step);
@@ -597,7 +581,7 @@ export function WizardPage() {
goToStep(emptyFields[0]?.step ?? 1); goToStep(emptyFields[0]?.step ?? 1);
}} }}
> >
تکمیل فیلد ها تکمیل فیلدها
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+2 -1
View File
@@ -11,10 +11,11 @@ import { persist } from "zustand/middleware";
export interface AuthUser { export interface AuthUser {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null; project_name: string | null;
+2
View File
@@ -41,6 +41,7 @@ interface AppStore {
deleteSupplier: (id: string) => void; deleteSupplier: (id: string) => void;
addMaterial: (m: Material) => void; addMaterial: (m: Material) => void;
addProject: (p: Project) => void; addProject: (p: Project) => void;
deleteProject: (id: string) => void;
setStandardConversion: (sc: StandardConversion) => void; setStandardConversion: (sc: StandardConversion) => void;
deleteStandardConversion: (id: string) => void; deleteStandardConversion: (id: string) => void;
@@ -85,6 +86,7 @@ export const useAppStore = create<AppStore>()((set) => ({
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })), deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })), addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })), addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
deleteProject: (id) => set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })),
setStandardConversion: (sc) => setStandardConversion: (sc) =>
set((s) => { set((s) => {
const idx = s.standardConversions.findIndex((x) => x.id === sc.id); const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
+11
View File
@@ -625,4 +625,15 @@ CREATE TABLE IF NOT EXISTS audit_log (
details JSONB NOT NULL DEFAULT '{}' details JSONB NOT NULL DEFAULT '{}'
); );
-- ---------- UNIFIED PROJECTS (PM & PROCUREMENT SHARED SCHEMA) ----------
ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
COMMIT; COMMIT;