Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1953e26356 | ||
|
|
df3ff4ea14 | ||
|
|
e9fef5a104 | ||
|
|
4a3c226518 | ||
|
|
c7d860f431 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f | ||
|
|
d69ec7f7a9 | ||
|
|
34290b9371 | ||
|
|
cff35e3660 | ||
|
|
b469238153 | ||
|
|
2fd72fecf7 | ||
|
|
ae62a0391b |
+79
-11
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.db.engine import AsyncSessionLocal
|
||||
from app.models.procurement import Project, User
|
||||
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||
from app.utils.errors import (
|
||||
ForbiddenError,
|
||||
ServiceUnavailableError,
|
||||
@@ -106,6 +106,10 @@ class UserContext:
|
||||
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
||||
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
||||
display_name: str
|
||||
# لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', 'inventory': ...}
|
||||
panels: dict | None = None
|
||||
# عضویت M:N در پروژهها — محدوده داده سطح limited (جدا از مسئولیت PM)
|
||||
member_project_ids: list | None = None
|
||||
|
||||
|
||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
if not casdoor_id:
|
||||
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))
|
||||
if user is None:
|
||||
user = User(
|
||||
casdoor_id=casdoor_id,
|
||||
username=username or None,
|
||||
first_name=str(
|
||||
claims.get("firstName")
|
||||
or claims.get("firstname")
|
||||
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
elif username and not user.username:
|
||||
user.username = username
|
||||
await db.flush()
|
||||
|
||||
project_ids: list = []
|
||||
if user.role == "pm":
|
||||
projects = (
|
||||
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
||||
panels: dict = {}
|
||||
panel_rows = (
|
||||
await db.execute(
|
||||
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
for row in panel_rows:
|
||||
panels[row.panel] = row.level
|
||||
|
||||
member_project_ids: list = [
|
||||
row.project_id
|
||||
for row in (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
select(UserProjectMembership)
|
||||
.where(UserProjectMembership.user_id == user.id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
project_ids = [p.id for p in projects]
|
||||
]
|
||||
|
||||
pm_projects = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
pm_project_ids_list = [p.id for p in pm_projects]
|
||||
|
||||
# ترکیب پروژههای تحت مدیریت مستقیم و پروژههای عضویت با حفظ اولویت و بدون تکرار
|
||||
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
|
||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||
@@ -155,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
project_id=project_id,
|
||||
project_ids=project_ids,
|
||||
display_name=display_name,
|
||||
panels=panels,
|
||||
member_project_ids=member_project_ids,
|
||||
)
|
||||
|
||||
|
||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||
"""Every project id managed by the given PM user (oldest first)."""
|
||||
rows = (
|
||||
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||
pm_rows = (
|
||||
await db.execute(
|
||||
select(Project.id)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
member_rows = (
|
||||
await db.execute(
|
||||
select(UserProjectMembership.project_id)
|
||||
.where(UserProjectMembership.user_id == user.id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
|
||||
|
||||
|
||||
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||
@@ -217,3 +257,31 @@ def require_role(*roles: str):
|
||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||
return ctx
|
||||
return _dep
|
||||
|
||||
|
||||
def require_procurement_writer():
|
||||
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
|
||||
(دوره مهاجرت از مدل نقش به مدل پنل)."""
|
||||
|
||||
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||
if ctx.role in ("admin", "procurement_manager"):
|
||||
return ctx
|
||||
if (ctx.panels or {}).get("procurement") == "full":
|
||||
return ctx
|
||||
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def allow_quote_create():
|
||||
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
|
||||
(محدود به پروژههای عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام میشود)."""
|
||||
|
||||
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||
if ctx.role in ("admin", "procurement_manager"):
|
||||
return ctx
|
||||
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
|
||||
return ctx
|
||||
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
|
||||
|
||||
return _dep
|
||||
|
||||
+17
-20
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_DASHBOARD_DDL = [
|
||||
# One reference unit per category (units.html «واحد مرجع هر دسته»)
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS category_reference_units (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||
ref_unit TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
|
||||
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS unit_conversions (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||
brand TEXT,
|
||||
from_unit TEXT NOT NULL,
|
||||
to_unit TEXT NOT NULL,
|
||||
factor NUMERIC(18,6) NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
|
||||
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
|
||||
""",
|
||||
@@ -193,7 +174,7 @@ _V26_DDL = [
|
||||
""",
|
||||
"""
|
||||
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 $$
|
||||
""",
|
||||
"""
|
||||
@@ -413,6 +394,22 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
""")
|
||||
|
||||
|
||||
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||||
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
|
||||
|
||||
These three tables backed features that were never populated (0 rows on every
|
||||
volume): per-category reference units and conversion factors (the
|
||||
per-reference-unit price line) and the category -> attribute mapping. Dropping
|
||||
them is idempotent and loses no data. material_categories itself stays — every
|
||||
supplier carries one as its label.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
|
||||
))
|
||||
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||||
|
||||
|
||||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||
ddl = """
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.db.engine import AsyncSessionLocal, engine
|
||||
from app.db.init_db import (
|
||||
ensure_attachment_table,
|
||||
ensure_audit_table,
|
||||
ensure_drop_category_tables,
|
||||
ensure_dashboard_tables,
|
||||
ensure_material_topic_column,
|
||||
ensure_quote_projects,
|
||||
@@ -105,6 +106,7 @@ async def lifespan(_app: FastAPI):
|
||||
await ensure_quote_projects(engine)
|
||||
await ensure_v26(engine)
|
||||
await ensure_audit_table(engine)
|
||||
await ensure_drop_category_tables(engine)
|
||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||
try:
|
||||
yield
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.db.base import Base
|
||||
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
||||
|
||||
user_role_enum = Enum(
|
||||
"pm", "procurement_manager", "admin",
|
||||
"pm", "procurement_manager", "admin", "supervisor",
|
||||
name="user_role", native_enum=True,
|
||||
)
|
||||
quote_status_enum = Enum(
|
||||
@@ -58,6 +58,7 @@ class User(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
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)
|
||||
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -65,6 +66,47 @@ class User(Base):
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
|
||||
|
||||
class UserPanel(Base):
|
||||
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
|
||||
|
||||
panel: 'procurement' | 'inventory' | 'pm'
|
||||
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژهها
|
||||
(بهجز تغییر موجودی)
|
||||
'limited' — تدارکات: فقط افزودن استعلام برای پروژههای عضویت؛
|
||||
انبار: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||
"""
|
||||
|
||||
__tablename__ = "user_panels"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
panel: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
level: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
|
||||
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
|
||||
|
||||
|
||||
class UserProjectMembership(Base):
|
||||
"""user_project_members — عضویت M:N کاربر در پروژهها، جدا از مسئولیت
|
||||
پروژه (projects.pm_user_id). محدودهی دادهی سطح limited را تعیین میکند."""
|
||||
|
||||
__tablename__ = "user_project_members"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
@@ -148,12 +190,23 @@ class Project(Base):
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
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)
|
||||
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).
|
||||
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -4,8 +4,6 @@ from app.routers import (
|
||||
auth,
|
||||
brand_materials,
|
||||
brands,
|
||||
category_attributes,
|
||||
category_ref_units,
|
||||
files,
|
||||
inventory,
|
||||
item_decisions,
|
||||
@@ -24,7 +22,6 @@ from app.routers import (
|
||||
supplier_materials,
|
||||
suppliers,
|
||||
termsheets,
|
||||
unit_conversions,
|
||||
units,
|
||||
users,
|
||||
)
|
||||
@@ -35,7 +32,6 @@ ROUTERS = [
|
||||
brands.router,
|
||||
material_categories.router,
|
||||
attribute_definitions.router,
|
||||
category_attributes.router,
|
||||
materials.router,
|
||||
brand_materials.router,
|
||||
suppliers.router,
|
||||
@@ -51,8 +47,6 @@ ROUTERS = [
|
||||
item_receipts.router,
|
||||
quote_events.router,
|
||||
files.router,
|
||||
category_ref_units.router,
|
||||
unit_conversions.router,
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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)
|
||||
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))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
||||
|
||||
@router.patch("/{definition_id}")
|
||||
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)
|
||||
if ad is None:
|
||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
||||
|
||||
|
||||
@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)
|
||||
if ad is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.routers.users import _user_projects, user_to_dict
|
||||
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
|
||||
|
||||
logger = logging.getLogger("procurement.auth")
|
||||
@@ -78,6 +78,9 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
||||
claims = await _verify_token(access_token)
|
||||
ctx = await context_from_claims(claims, db)
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
|
||||
if ctx.role == "supervisor" and not has_projects:
|
||||
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||
await analytics.track(
|
||||
"login_success",
|
||||
{"username": payload.username, "role": ctx.role},
|
||||
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": body.get("expires_in"),
|
||||
"user": user_to_dict(ctx.user, projects),
|
||||
"user": await user_to_dict(ctx.user, projects, db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial, Material
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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)
|
||||
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:
|
||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||
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}")
|
||||
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)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
||||
|
||||
|
||||
@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)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def brand_to_dict(brand: Brand) -> dict:
|
||||
@@ -42,7 +44,11 @@ async def list_brands(
|
||||
|
||||
|
||||
@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()
|
||||
if any(b.country == payload.country for b in rows):
|
||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@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)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
||||
|
||||
|
||||
@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)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Category attributes — scoped attribute definitions per category (P2 set).
|
||||
|
||||
Identified by composite key {category_id}/{attribute_definition_id}.
|
||||
The nested `attribute` object is ALWAYS included (not expand-based).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
|
||||
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/material-categories/{category_id}/attributes",
|
||||
tags=["category-attributes"],
|
||||
)
|
||||
|
||||
|
||||
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
|
||||
return {
|
||||
"id": str(ca.id),
|
||||
"category_id": str(ca.category_id),
|
||||
"attribute_definition_id": str(ca.attribute_definition_id),
|
||||
"is_required": ca.is_required,
|
||||
"is_filterable": ca.is_filterable,
|
||||
"conditional_rules": ca.conditional_rules,
|
||||
"sort_order": ca.sort_order,
|
||||
"attribute": {
|
||||
"attribute_key": attribute.attribute_key,
|
||||
"label": attribute.label,
|
||||
"data_type": attribute.data_type,
|
||||
"unit_id": attribute.unit_id,
|
||||
"enum_values": attribute.enum_values,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
return category
|
||||
|
||||
|
||||
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
|
||||
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
|
||||
return ca_to_dict(ca, attribute)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_category_attributes(
|
||||
category_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _category_or_404(db, category_id)
|
||||
stmt = (
|
||||
select(CategoryAttribute)
|
||||
.where(CategoryAttribute.category_id == category_id)
|
||||
.order_by(CategoryAttribute.sort_order.asc())
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category_attribute(
|
||||
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
await _category_or_404(db, category_id)
|
||||
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
|
||||
duplicate = await db.scalar(
|
||||
select(CategoryAttribute.id).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این ویژگی قبلاً به این دستهبندی اضافه شده است", field="attribute_definition_id")
|
||||
ca = CategoryAttribute(
|
||||
category_id=category_id,
|
||||
attribute_definition_id=payload.attribute_definition_id,
|
||||
is_required=payload.is_required,
|
||||
is_filterable=payload.is_filterable,
|
||||
conditional_rules=payload.conditional_rules,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(ca)
|
||||
await db.flush()
|
||||
return {"data": await _with_attribute(db, ca)}
|
||||
|
||||
|
||||
@router.patch("/{attribute_definition_id}")
|
||||
async def update_category_attribute(
|
||||
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ca = await db.scalar(
|
||||
select(CategoryAttribute).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if ca is None:
|
||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
||||
if payload.is_required is not None:
|
||||
ca.is_required = payload.is_required
|
||||
if payload.is_filterable is not None:
|
||||
ca.is_filterable = payload.is_filterable
|
||||
if payload.conditional_rules is not None:
|
||||
ca.conditional_rules = payload.conditional_rules
|
||||
if payload.sort_order is not None:
|
||||
ca.sort_order = payload.sort_order
|
||||
await db.flush()
|
||||
return {"data": await _with_attribute(db, ca)}
|
||||
|
||||
|
||||
@router.delete("/{attribute_definition_id}")
|
||||
async def delete_category_attribute(
|
||||
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ca = await db.scalar(
|
||||
select(CategoryAttribute).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if ca is None:
|
||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
||||
await db.delete(ca)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Category reference units — units.html dashboard (P2-26/27).
|
||||
|
||||
One reference unit per category (UNIQUE category_id); PUT is an upsert.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
|
||||
from app.schemas.catalog import CategoryReferenceUnitPut
|
||||
from app.services.expand_service import category_brief
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
|
||||
|
||||
|
||||
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(cru.id),
|
||||
"category_id": str(cru.category_id),
|
||||
"ref_unit": cru.ref_unit,
|
||||
"updated_at": to_iso(cru.updated_at),
|
||||
}
|
||||
if "category" in expand and hasattr(cru, "_category"):
|
||||
d["category"] = category_brief(cru._category)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_category_reference_units(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(CategoryReferenceUnit)
|
||||
if category_id:
|
||||
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "category" in tokens:
|
||||
cat_ids = {cru.category_id for cru in items}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
for cru in items:
|
||||
cru._category = cats.get(cru.category_id)
|
||||
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
|
||||
|
||||
|
||||
@router.put("/{category_id}")
|
||||
async def upsert_category_reference_unit(
|
||||
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
|
||||
if cru is None:
|
||||
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
|
||||
db.add(cru)
|
||||
else:
|
||||
cru.ref_unit = payload.ref_unit
|
||||
await db.flush()
|
||||
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
|
||||
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||
|
||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
||||
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||
# برای هر کاربر واردشده آزاد است.
|
||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||
_attachment_writer = require_role("procurement_manager", "admin")
|
||||
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||
@@ -125,7 +126,7 @@ async def _attachment_or_404(
|
||||
# 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)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
|
||||
@@ -58,6 +58,7 @@ class Scope:
|
||||
role: str
|
||||
project_id: uuid.UUID | None
|
||||
actor: str | None
|
||||
project_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
async def inventory_scope(
|
||||
@@ -75,25 +76,41 @@ async def inventory_scope(
|
||||
if request.headers.get("authorization"):
|
||||
user: UserContext = await get_optional_user(request, db)
|
||||
if user is not None:
|
||||
if user.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
||||
if user.role == "procurement_manager":
|
||||
panels = user.panels or {}
|
||||
if user.role is None and not panels:
|
||||
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
|
||||
# ادمین: دستنخورده — همهچیز از جمله تغییر موجودی
|
||||
if user.role == "admin":
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
if user.role not in ("pm", "admin"):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
||||
if user.role == "pm":
|
||||
allowed = list(user.project_ids or [])
|
||||
# لایه پنلها (جایگزین نقشها): پنل انبار اولویت دارد
|
||||
if "inventory" in panels:
|
||||
if panels["inventory"] == "full":
|
||||
# کل سیستم و تمام پروژهها — بهجز تغییر موجودی
|
||||
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
|
||||
# limited: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||
allowed = list(user.member_project_ids or [])
|
||||
if not allowed:
|
||||
raise ForbiddenError("پروژهای به شما تخصیص داده نشده است")
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
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, project_ids=allowed)
|
||||
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||
if user.role in ("procurement_manager", "pm"):
|
||||
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
|
||||
if user.role == "supervisor":
|
||||
allowed = list(user.project_ids or [])
|
||||
if not allowed:
|
||||
raise ValidationError(
|
||||
"کاربر 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)
|
||||
raise ForbiddenError("پروژه تخصیص داده نشده است")
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||
if user.role == "admin":
|
||||
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
|
||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||
@@ -128,9 +145,9 @@ async def inventory_scope(
|
||||
return Scope(role=role, project_id=project_id, actor=actor)
|
||||
|
||||
|
||||
def _require_pm(scope: Scope) -> None:
|
||||
if scope.role != ROLE_PM:
|
||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
||||
def _require_inventory_modifier(scope: Scope) -> None:
|
||||
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -215,8 +232,16 @@ async def list_warehouses(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Warehouse)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
elif allowed:
|
||||
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||
else:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
stmt = stmt.order_by(Warehouse.name)
|
||||
@@ -248,9 +273,11 @@ async def create_warehouse(
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
||||
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if payload.project_id not in allowed:
|
||||
raise ValidationError("شما فقط میتوانید برای پروژه تخصیصیافته به خود انبار بسازید", field="project_id")
|
||||
if await db.get(Project, payload.project_id) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||
@@ -267,8 +294,9 @@ async def update_warehouse(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.project_id != wh.project_id:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.name is not None:
|
||||
wh.name = payload.name
|
||||
@@ -292,8 +320,10 @@ async def delete_warehouse(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||
# inventory cascade — block deletion if any stock history exists.
|
||||
linked = await db.scalar(
|
||||
@@ -314,7 +344,6 @@ async def delete_warehouse(
|
||||
@router.get("")
|
||||
async def list_inventory(
|
||||
project_id: uuid.UUID | None = None,
|
||||
category_id=None,
|
||||
warehouse_id=None,
|
||||
material_id=None,
|
||||
sort: str | None = None,
|
||||
@@ -324,18 +353,26 @@ async def list_inventory(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(InventoryItem)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == scope.project_id
|
||||
)
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == project_id
|
||||
)
|
||||
elif allowed:
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id.in_(allowed)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == scope.project_id
|
||||
)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == project_id
|
||||
)
|
||||
if category_id is not None:
|
||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
||||
Material.category_id == category_id
|
||||
)
|
||||
if warehouse_id is not None:
|
||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||
if material_id is not None:
|
||||
@@ -357,10 +394,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
||||
unit_ids = {i.unit_id for i in items}
|
||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
categories = {}
|
||||
if materials:
|
||||
cat_ids = {m.category_id for m in materials.values()}
|
||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
brands = {}
|
||||
if brand_ids:
|
||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||
@@ -370,7 +403,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
||||
return [
|
||||
inventory_to_dict(
|
||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
||||
None, brands.get(i.brand_id), units.get(i.unit_id),
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
@@ -397,8 +430,16 @@ async def list_transactions(
|
||||
stmt = select(InventoryTransaction).join(
|
||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
elif allowed:
|
||||
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||
else:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
if warehouse_id is not None:
|
||||
@@ -463,8 +504,10 @@ async def inventory_history(
|
||||
if item is None:
|
||||
raise NotFoundError("موجودی یافت نشد")
|
||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||
txs = (await db.execute(
|
||||
select(InventoryTransaction)
|
||||
.where(InventoryTransaction.inventory_id == inventory_id)
|
||||
@@ -483,18 +526,20 @@ async def create_transaction(
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_require_pm(scope)
|
||||
_require_inventory_modifier(scope)
|
||||
# No explicit db.begin(): the session may already be inside an implicit
|
||||
# transaction (inventory_scope read from it in the JWT path); get_db
|
||||
# commits on success / rolls back on failure.
|
||||
with nullcontext():
|
||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||
if wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.dest_warehouse_id is not None:
|
||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||
if dest.project_id != scope.project_id:
|
||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.dest_warehouse_id is not None:
|
||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||
if dest.project_id not in allowed:
|
||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||
await audit_record(
|
||||
db,
|
||||
@@ -532,58 +577,3 @@ async def transaction_types():
|
||||
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
||||
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
||||
)]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material selector — filtered by Category + Warehouse (transaction forms)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/materials/options")
|
||||
async def material_options(
|
||||
category_id: uuid.UUID,
|
||||
warehouse_id: uuid.UUID | None = None,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Materials for the transaction form.
|
||||
|
||||
Always filtered by category. When `warehouse_id` is given, only materials
|
||||
that already exist in that warehouse's inventory are returned (so the PM
|
||||
works with the actual stock of that warehouse); otherwise the full
|
||||
category list is returned (e.g. first purchase of a new material).
|
||||
"""
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
|
||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
||||
if warehouse_id is not None:
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
stmt = stmt.where(
|
||||
Material.id.in_(
|
||||
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(Material.name)
|
||||
materials = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
# Brands per material (from brand_materials catalog + brands actually used in stock)
|
||||
brand_rows = (await db.execute(
|
||||
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
||||
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
||||
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
|
||||
)).all()
|
||||
brands_by_material: dict[str, list[dict]] = {}
|
||||
for mat_id, b_id, b_name in brand_rows:
|
||||
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
|
||||
|
||||
return {"data": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"brands": brands_by_material.get(str(m.id), []),
|
||||
}
|
||||
for m in materials
|
||||
]}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||
tags=["item-receipts"],
|
||||
)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
decision = await _decision_or_404(db, line_item_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("")
|
||||
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)
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||
tags=["item-termsheets"],
|
||||
)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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)
|
||||
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():
|
||||
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}")
|
||||
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)
|
||||
quote = await db.get(Quote, quote_id)
|
||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
||||
|
||||
@router.delete("/{item_termsheet_id}")
|
||||
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(
|
||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Unit
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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("")
|
||||
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)."""
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, Unit
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
||||
|
||||
|
||||
@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():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
||||
|
||||
@router.patch("/{line_item_id}")
|
||||
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)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
@@ -131,7 +142,12 @@ async def update_line_item(
|
||||
|
||||
|
||||
@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)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
|
||||
@@ -3,14 +3,17 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Supplier
|
||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def category_to_dict(category: MaterialCategory) -> dict:
|
||||
@@ -45,7 +48,11 @@ async def list_categories(
|
||||
|
||||
|
||||
@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))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||
@@ -69,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@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)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
@@ -91,14 +103,22 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
||||
|
||||
|
||||
@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)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
||||
# v2.7: materials no longer carry a category, so what blocks deletion is the
|
||||
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
|
||||
# material of that category is reached through those suppliers.
|
||||
linked = await db.scalar(
|
||||
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
|
||||
)
|
||||
if linked:
|
||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
||||
raise ConflictError(f"این دستهبندی برای {linked} تأمینکننده ثبت شده و قابل حذف نیست")
|
||||
await db.delete(category) # category_attributes cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(category_id), "deleted": True}}
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import (
|
||||
AttributeDefinition,
|
||||
@@ -18,7 +19,7 @@ from app.models.catalog import (
|
||||
MaterialUnit,
|
||||
Unit,
|
||||
)
|
||||
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
||||
from app.models.procurement import LineItem
|
||||
from app.schemas.catalog import (
|
||||
MaterialAttributeValuesPut,
|
||||
MaterialCreate,
|
||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def _coerce_filter_value(data_type: str, raw: str):
|
||||
@@ -97,7 +99,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
|
||||
async def list_materials(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
brand_id=None,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
@@ -107,17 +108,6 @@ async def list_materials(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Material)
|
||||
if category_id:
|
||||
sub = (
|
||||
select(1)
|
||||
.select_from(SupplierMaterial)
|
||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(
|
||||
SupplierMaterial.material_id == Material.id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(exists(sub))
|
||||
if brand_id:
|
||||
# WHERE EXISTS brand_materials with that brand_id
|
||||
sub = select(1).where(
|
||||
@@ -137,7 +127,10 @@ async def list_materials(
|
||||
|
||||
@router.post("", status_code=201)
|
||||
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)
|
||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||
@@ -152,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
||||
|
||||
|
||||
@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)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
@@ -161,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
||||
|
||||
|
||||
@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)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
@@ -260,7 +262,10 @@ async def list_material_units(
|
||||
|
||||
@router.post("/{material_id}/units", status_code=201)
|
||||
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():
|
||||
await _material_or_404(db, material_id)
|
||||
@@ -299,7 +304,11 @@ async def create_material_unit(
|
||||
|
||||
@router.patch("/{material_id}/units/{mu_id}")
|
||||
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():
|
||||
mu = await db.scalar(
|
||||
@@ -331,7 +340,12 @@ async def update_material_unit(
|
||||
|
||||
|
||||
@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(
|
||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||
)
|
||||
@@ -377,7 +391,10 @@ async def get_material_attribute_values(
|
||||
|
||||
@router.put("/{material_id}/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)."""
|
||||
async with db.begin():
|
||||
|
||||
+41
-27
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
||||
from app.models.catalog import BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -71,16 +71,17 @@ class ArrivalScope:
|
||||
user_id: uuid.UUID | None
|
||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||
display_name: str
|
||||
project_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
async def arrivals_scope(
|
||||
request: Request,
|
||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای دادههای پروژه)"),
|
||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: دادههای پروژه)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ArrivalScope:
|
||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||
|
||||
PM → scoped to the requested project, which must be one they manage
|
||||
PM / supervisor → scoped to the requested project, which must be one they manage
|
||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||
Admin / procurement manager → all projects (param = optional filter).
|
||||
"""
|
||||
@@ -89,19 +90,19 @@ async def arrivals_scope(
|
||||
if ctx is not None:
|
||||
if ctx.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
if ctx.role == "pm":
|
||||
if ctx.role in ("pm", "supervisor"):
|
||||
allowed = list(ctx.project_ids or [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
|
||||
if not allowed:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
|
||||
if ctx.role in ("admin", "procurement_manager"):
|
||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
||||
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
|
||||
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
|
||||
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||
# Disabled by default: an ungated header identity in production would
|
||||
# let anyone confirm receipts as PM.
|
||||
@@ -110,8 +111,10 @@ async def arrivals_scope(
|
||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role == "procurement_manager":
|
||||
role = "admin"
|
||||
elif role == "supervisor":
|
||||
role = "pm"
|
||||
if role not in ("pm", "admin"):
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||
# The explicit query param wins; otherwise the dev header carries it.
|
||||
if project_id is None and role == "pm":
|
||||
raw_project = request.headers.get("x-project-id")
|
||||
@@ -189,10 +192,17 @@ async def pm_on_the_way(
|
||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||
.where(ItemDecision.status == "chosen")
|
||||
)
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
if ctx.project_id is not None:
|
||||
# PM: active project; admin/manager: optional project filter
|
||||
if ctx.role == "pm":
|
||||
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||
if not allowed:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
if ctx.project_id is not None:
|
||||
if ctx.project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
else:
|
||||
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||
elif ctx.project_id is not None:
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||
|
||||
@@ -201,12 +211,6 @@ async def pm_on_the_way(
|
||||
# unit / category lookups for display
|
||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
||||
cats = {}
|
||||
if sup_cat_ids:
|
||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
||||
cats = {c.id: c for c in rows}
|
||||
|
||||
out = []
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
received = received_map.get(d.id)
|
||||
@@ -219,8 +223,7 @@ async def pm_on_the_way(
|
||||
"supplier": {"id": str(sup.id), "name": sup.name},
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"material": {"id": str(m.id), "name": m.name,
|
||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
|
||||
"material": {"id": str(m.id), "name": m.name},
|
||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||
"ordered_qty": str(li.quantity),
|
||||
@@ -250,8 +253,12 @@ async def pm_confirm_receipt(
|
||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||
if decision is None or decision.status != "chosen":
|
||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||
if ctx.role == "pm":
|
||||
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||
if decision.project_id not in allowed:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||
|
||||
li = await db.get(LineItem, decision.line_item_id)
|
||||
quote = await db.get(Quote, li.quote_id)
|
||||
@@ -458,10 +465,17 @@ async def pm_receipts(
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Project, Project.id == ItemDecision.project_id)
|
||||
)
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
if ctx.project_id is not None:
|
||||
# PM: active project; admin/manager: optional project filter
|
||||
if ctx.role == "pm":
|
||||
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||
if not allowed:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
if ctx.project_id is not None:
|
||||
if ctx.project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
else:
|
||||
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||
elif ctx.project_id is not None:
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||
receipts = [r.ItemReceipt for r in rows]
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, Quote, User
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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 = {
|
||||
"id": str(project.id),
|
||||
"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),
|
||||
"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,
|
||||
"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)
|
||||
if pm:
|
||||
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
|
||||
|
||||
|
||||
@@ -55,7 +62,14 @@ async def list_projects(
|
||||
):
|
||||
stmt = select(Project)
|
||||
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)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
pm_map = await _pm_map(db, items)
|
||||
@@ -64,24 +78,38 @@ async def list_projects(
|
||||
d = {
|
||||
"id": str(p.id),
|
||||
"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),
|
||||
"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,
|
||||
"created_at": to_iso(p.created_at),
|
||||
}
|
||||
pm = pm_map.get(p.pm_user_id)
|
||||
if pm:
|
||||
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)
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@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(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
name=payload.name.strip(),
|
||||
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,
|
||||
planned_start=payload.start_date,
|
||||
status="ACTIVE",
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
@@ -103,37 +131,51 @@ async def update_project(
|
||||
request: Request,
|
||||
ctx: UserContext | None = Depends(_optional_ctx),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
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:
|
||||
project.description = payload.description
|
||||
project.description = payload.description.strip()
|
||||
if payload.start_date is not None:
|
||||
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.
|
||||
# Admin / procurement manager only (same rule as the Organization panel).
|
||||
if "pm_user_id" in payload.model_fields_set:
|
||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role not in ("admin", "procurement_manager"):
|
||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
||||
if role not in ("admin", "procurement_manager", "pm"):
|
||||
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||
if payload.pm_user_id is None:
|
||||
project.pm_user_id = None
|
||||
else:
|
||||
pm_user = await db.get(User, payload.pm_user_id)
|
||||
if pm_user is None:
|
||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||
if pm_user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
||||
if pm_user.role not in ("supervisor", "pm"):
|
||||
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="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()
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@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)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote, QuoteEvent
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -34,7 +36,12 @@ async def list_events(
|
||||
|
||||
|
||||
@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)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material
|
||||
from app.models.procurement import (
|
||||
@@ -36,23 +37,18 @@ from app.services.expand_service import (
|
||||
serialize_quote_with_children,
|
||||
serialize_quotes,
|
||||
)
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import sort_query
|
||||
from app.utils.security import get_actor
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||
|
||||
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
# --- list filter helpers ---------------------------------------------------
|
||||
|
||||
def _quote_category_exists(category_id) -> exists:
|
||||
return exists(
|
||||
select(1).where(
|
||||
Supplier.id == Quote.supplier_id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _quote_search(q: str) -> exists:
|
||||
like = f"%{q}%"
|
||||
@@ -80,7 +76,6 @@ async def get_quote_stats(
|
||||
project_id=None,
|
||||
quote_date_from: datetime | None = None,
|
||||
quote_date_to: datetime | None = None,
|
||||
category_id=None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {
|
||||
@@ -90,7 +85,6 @@ async def get_quote_stats(
|
||||
project_id=project_id,
|
||||
quote_date_from=quote_date_from,
|
||||
quote_date_to=quote_date_to,
|
||||
category_id=category_id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,7 +100,6 @@ async def list_quotes(
|
||||
status: str | None = None,
|
||||
supplier_id=None,
|
||||
project_id=None,
|
||||
category_id=None,
|
||||
quote_date_from: datetime | None = None,
|
||||
quote_date_to: datetime | None = None,
|
||||
q: str | None = None,
|
||||
@@ -137,8 +130,6 @@ async def list_quotes(
|
||||
)
|
||||
)
|
||||
)
|
||||
if category_id:
|
||||
stmt = stmt.where(_quote_category_exists(category_id))
|
||||
if quote_date_from is not None:
|
||||
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
||||
if quote_date_to is not None:
|
||||
@@ -169,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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),
|
||||
ctx: UserContext = Depends(allow_quote_create()),
|
||||
_auth=None,
|
||||
):
|
||||
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||
if ctx.project_id:
|
||||
allowed.add(ctx.project_id)
|
||||
allowed_str = {str(pid) for pid in allowed if pid}
|
||||
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||
if not wanted:
|
||||
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||
if not wanted <= allowed_str:
|
||||
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||
quote = await quote_service.create_quote(db, payload, actor)
|
||||
return {"data": await serialize_quote_with_children(db, quote)}
|
||||
|
||||
@@ -180,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
||||
|
||||
@router.patch("/{quote_id}")
|
||||
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)
|
||||
return {"data": await serialize_quote(db, quote, set())}
|
||||
@@ -188,7 +200,11 @@ async def update_quote(
|
||||
|
||||
@router.patch("/{quote_id}/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)."""
|
||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||
@@ -201,7 +217,11 @@ async def replace_quote_children(
|
||||
|
||||
@router.patch("/{quote_id}/accept")
|
||||
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)
|
||||
return {"data": await _accept_response(db, quote)}
|
||||
@@ -237,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
||||
|
||||
|
||||
@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)
|
||||
return {
|
||||
"data": {
|
||||
@@ -250,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
||||
|
||||
@router.patch("/{quote_id}/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)
|
||||
return {
|
||||
@@ -272,14 +301,23 @@ async def arrive_items(
|
||||
|
||||
@router.patch("/{quote_id}/reject")
|
||||
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)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
|
||||
@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)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
@@ -289,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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)
|
||||
return {"data": result}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||
tags=["schedule-steps"],
|
||||
)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
step_no = payload.step_no
|
||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
||||
|
||||
@router.patch("/{step_id}")
|
||||
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)
|
||||
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}")
|
||||
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)
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import StandardConversion
|
||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
||||
|
||||
|
||||
@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(
|
||||
select(StandardConversion.id).where(
|
||||
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}")
|
||||
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)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
||||
|
||||
|
||||
@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)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.models.procurement import Supplier, SupplierMaterial
|
||||
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(tags=["supplier-materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
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)
|
||||
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():
|
||||
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}")
|
||||
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():
|
||||
await unlink_supplier_material(db, supplier_id, material_id)
|
||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
||||
|
||||
|
||||
@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)
|
||||
supplier = Supplier(
|
||||
name=payload.name,
|
||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@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)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
||||
|
||||
|
||||
@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)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
||||
|
||||
|
||||
@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():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
||||
|
||||
@router.patch("/{termsheet_id}")
|
||||
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)
|
||||
if ts.status != "offered":
|
||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
||||
|
||||
@router.patch("/{termsheet_id}/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():
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
||||
|
||||
|
||||
@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)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
|
||||
|
||||
Model: {category_id, brand?, from_unit, to_unit, factor}
|
||||
1 from_unit = factor × to_unit
|
||||
brand empty = all brands of the category; brand set = that brand only.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory, UnitConversion
|
||||
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
|
||||
from app.services.expand_service import category_brief
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
|
||||
|
||||
|
||||
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(uc.id),
|
||||
"category_id": str(uc.category_id),
|
||||
"brand": uc.brand,
|
||||
"from_unit": uc.from_unit,
|
||||
"to_unit": uc.to_unit,
|
||||
"factor": float(uc.factor),
|
||||
"updated_at": to_iso(uc.updated_at),
|
||||
}
|
||||
if "category" in expand and hasattr(uc, "_category"):
|
||||
d["category"] = category_brief(uc._category)
|
||||
return d
|
||||
|
||||
|
||||
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
|
||||
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
|
||||
where = (
|
||||
UnitConversion.category_id == category_id,
|
||||
UnitConversion.from_unit == from_unit,
|
||||
UnitConversion.to_unit == to_unit,
|
||||
)
|
||||
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده میشود
|
||||
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
|
||||
if uc_or_none is not None:
|
||||
where = where + (UnitConversion.id != uc_or_none.id,)
|
||||
return where
|
||||
|
||||
|
||||
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
|
||||
duplicate = await db.scalar(
|
||||
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این تبدیل قبلاً ثبت شده است")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_unit_conversions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
brand: str | None = None,
|
||||
from_unit: str | None = None,
|
||||
to_unit: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(UnitConversion)
|
||||
if category_id:
|
||||
stmt = stmt.where(UnitConversion.category_id == category_id)
|
||||
if brand:
|
||||
stmt = stmt.where(UnitConversion.brand == brand)
|
||||
if from_unit:
|
||||
stmt = stmt.where(UnitConversion.from_unit == from_unit)
|
||||
if to_unit:
|
||||
stmt = stmt.where(UnitConversion.to_unit == to_unit)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "category" in tokens:
|
||||
cat_ids = {uc.category_id for uc in items}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
for uc in items:
|
||||
uc._category = cats.get(uc.category_id)
|
||||
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, payload.category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
|
||||
uc = UnitConversion(
|
||||
category_id=payload.category_id,
|
||||
brand=payload.brand,
|
||||
from_unit=payload.from_unit,
|
||||
to_unit=payload.to_unit,
|
||||
factor=payload.factor,
|
||||
)
|
||||
db.add(uc)
|
||||
await db.flush()
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.get("/{uc_id}")
|
||||
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.patch("/{uc_id}")
|
||||
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
new_brand = payload.brand if payload.brand is not None else uc.brand
|
||||
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
|
||||
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
|
||||
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
|
||||
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
|
||||
if payload.brand is not None:
|
||||
uc.brand = payload.brand
|
||||
if payload.from_unit is not None:
|
||||
uc.from_unit = payload.from_unit
|
||||
if payload.to_unit is not None:
|
||||
uc.to_unit = payload.to_unit
|
||||
if payload.factor is not None:
|
||||
uc.factor = payload.factor
|
||||
await db.flush()
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.delete("/{uc_id}")
|
||||
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
await db.delete(uc)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/units", tags=["units"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def unit_full_to_dict(unit: Unit) -> dict:
|
||||
@@ -45,7 +47,11 @@ async def list_units(
|
||||
|
||||
|
||||
@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))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@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)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
||||
|
||||
|
||||
@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)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
|
||||
+186
-31
@@ -17,15 +17,21 @@ import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, select, update, text, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_current_user, require_role
|
||||
from app.utils.audit import record as audit_record
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, User
|
||||
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
||||
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||
from app.schemas.procurement import (
|
||||
UserCreate,
|
||||
UserPanelsUpdate,
|
||||
UserPanelProjectsUpdate,
|
||||
UserProjectUpdate,
|
||||
UserRoleUpdate,
|
||||
)
|
||||
from app.utils.errors import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
|
||||
"pm": "مدیر پروژه",
|
||||
"procurement_manager": "مدیر تدارکات",
|
||||
"admin": "ادمین",
|
||||
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||
"supervisor": "سرپرست پروژه",
|
||||
}
|
||||
|
||||
|
||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
PANEL_LEVELS = ("full", "limited")
|
||||
PANELS = ("procurement", "inventory", "pm")
|
||||
|
||||
|
||||
async def _panels_of(db: AsyncSession, user_id) -> dict:
|
||||
"""panel -> level map for the user (لایه پنلها — جایگزین نقشها)."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return {row.panel: row.level for row in rows}
|
||||
|
||||
|
||||
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
|
||||
"""عضویت M:N کاربر در پروژهها (جدا از مسئولیت) — رشته."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserProjectMembership.project_id)
|
||||
.where(UserProjectMembership.user_id == user_id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return [str(pid) for pid in rows]
|
||||
|
||||
|
||||
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
|
||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||
project_id/project_name stay as the first (oldest) one for compatibility."""
|
||||
project_id/project_name stay as the first (oldest) one for compatibility.
|
||||
با db، پنلهای تخصیصی و عضویت پروژهای هم برمیگردند."""
|
||||
projects = projects or []
|
||||
first = projects[0] if projects else None
|
||||
return {
|
||||
out = {
|
||||
"id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
@@ -62,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||
"created_at": to_iso(user.created_at),
|
||||
}
|
||||
if db is not None:
|
||||
out["panels"] = await _panels_of(db, user.id)
|
||||
member_pids = await _member_project_ids_of(db, user.id)
|
||||
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
|
||||
out["member_project_ids"] = all_pids
|
||||
return out
|
||||
|
||||
|
||||
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||
"""Admin OR procurement manager (the same person) — organization management."""
|
||||
if ctx.role not in ("admin", "procurement_manager"):
|
||||
"""Admin, procurement manager, or project manager — organization management."""
|
||||
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||
return ctx
|
||||
|
||||
|
||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
||||
if user.role != "pm":
|
||||
"""Every project assigned to the user (supervisor or PM) (oldest first).
|
||||
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
|
||||
"""
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
return []
|
||||
member_subq = select(UserProjectMembership.project_id).where(
|
||||
UserProjectMembership.user_id == user.id
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.where(
|
||||
or_(
|
||||
Project.pm_user_id == user.id,
|
||||
Project.id.in_(member_subq),
|
||||
)
|
||||
)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
|
||||
@router.get("/me")
|
||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {"data": user_to_dict(ctx.user, projects)}
|
||||
return {"data": await user_to_dict(ctx.user, projects, db)}
|
||||
|
||||
|
||||
@router.get("/me/projects")
|
||||
@@ -138,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
|
||||
active project against this list and falls back gracefully when a
|
||||
project is revoked."""
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
# عضویتهای M:N (لایه پنلها) هم به فهرست اضافه میشود
|
||||
member_ids = set(ctx.member_project_ids or [])
|
||||
have = {p.id for p in projects}
|
||||
if member_ids:
|
||||
extra = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.id.in_(member_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
projects = list(projects) + [p for p in extra if p.id not in have]
|
||||
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
||||
|
||||
|
||||
@@ -148,20 +212,20 @@ async def list_users(
|
||||
):
|
||||
"""Every user (with role + assigned projects) — Organization panel."""
|
||||
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 = {}
|
||||
if pm_ids:
|
||||
if supervisor_ids:
|
||||
projs = (
|
||||
await db.execute(
|
||||
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)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||
"meta": {"total": len(users)},
|
||||
}
|
||||
|
||||
@@ -187,30 +251,30 @@ async def create_user(
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
||||
return {"data": user_to_dict(user)}
|
||||
return {"data": await user_to_dict(user)}
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def update_user_role(
|
||||
user_id,
|
||||
payload: UserRoleUpdate,
|
||||
ctx: UserContext = Depends(require_role("admin")),
|
||||
ctx: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
||||
unassigns the user from their project."""
|
||||
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||
if payload.role == "admin" and ctx.role != "admin":
|
||||
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
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)
|
||||
user.role = payload.role
|
||||
await db.flush()
|
||||
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
||||
projects = await _user_projects(db, user)
|
||||
return {"data": user_to_dict(user, projects)}
|
||||
return {"data": await user_to_dict(user, projects)}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
|
||||
continue
|
||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||
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(
|
||||
casdoor_id=cid,
|
||||
username=uname,
|
||||
first_name=str(
|
||||
cu.get("firstName")
|
||||
or cu.get("displayName")
|
||||
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
|
||||
phone=str(cu.get("phone") or "") or None,
|
||||
))
|
||||
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()
|
||||
|
||||
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 = {}
|
||||
if pm_ids:
|
||||
if supervisor_ids:
|
||||
projs = (
|
||||
await db.execute(
|
||||
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)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||
"meta": {"total": len(users), "created": created},
|
||||
}
|
||||
|
||||
@@ -282,17 +357,97 @@ async def assign_user_project(
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
if user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||
|
||||
if payload.project_id is None:
|
||||
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()
|
||||
return {"data": user_to_dict(user)}
|
||||
return {"data": await user_to_dict(user)}
|
||||
|
||||
project = await db.get(Project, payload.project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_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()
|
||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
||||
return {"data": await user_to_dict(user, await _user_projects(db, user))}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# پنلها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.put("/{user_id}/panels")
|
||||
@router.patch("/{user_id}/panels")
|
||||
async def set_user_panels(
|
||||
user_id,
|
||||
payload: UserPanelsUpdate,
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
for panel, level in payload.panels.items():
|
||||
if panel not in PANELS:
|
||||
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
|
||||
if level not in PANEL_LEVELS:
|
||||
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
|
||||
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
|
||||
for panel, level in payload.panels.items():
|
||||
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
|
||||
await db.flush()
|
||||
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||
|
||||
|
||||
@router.put("/{user_id}/panel-projects")
|
||||
@router.patch("/{user_id}/panel-projects")
|
||||
async def set_user_panel_projects(
|
||||
user_id,
|
||||
payload: UserPanelProjectsUpdate,
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
wanted = list(dict.fromkeys(payload.project_ids))
|
||||
if wanted:
|
||||
found = (
|
||||
await db.execute(select(Project).where(Project.id.in_(wanted)))
|
||||
).scalars().all()
|
||||
found_ids = {p.id for p in found}
|
||||
unknown = [str(pid) for pid in wanted if pid not in found_ids]
|
||||
if unknown:
|
||||
raise ValidationError(
|
||||
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||
)
|
||||
await db.execute(
|
||||
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
|
||||
)
|
||||
for pid in wanted:
|
||||
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
|
||||
await db.flush()
|
||||
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||
|
||||
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
|
||||
|
||||
class ProjectCreate(APIModel):
|
||||
name: str = Field(min_length=1)
|
||||
location: str | None = None
|
||||
project_manager: str | None = None
|
||||
description: str | None = None
|
||||
start_date: date | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(APIModel):
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
location: str | None = None
|
||||
project_manager: str | None = None
|
||||
description: str | 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).
|
||||
# Admin / procurement manager only; None unassigns this project's PM.
|
||||
pm_user_id: UUID | None = None
|
||||
@@ -203,13 +208,13 @@ class UserCreate(APIModel):
|
||||
last_name: str = ""
|
||||
phone: 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):
|
||||
"""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):
|
||||
@@ -341,3 +346,16 @@ class ReceiptUpdate(APIModel):
|
||||
received_qty: Decimal | None = Field(default=None, gt=0)
|
||||
on_time: bool | None = None
|
||||
quality_note: str | None = None
|
||||
|
||||
|
||||
class UserPanelsUpdate(APIModel):
|
||||
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
|
||||
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف میشود."""
|
||||
|
||||
panels: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserPanelProjectsUpdate(APIModel):
|
||||
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
|
||||
|
||||
project_ids: list[UUID] = Field(default_factory=list)
|
||||
|
||||
@@ -37,13 +37,14 @@ async def track(event: str, properties: dict[str, Any] | None = None, profile_id
|
||||
"""Record a named event; swallows every error."""
|
||||
if not _configured():
|
||||
return
|
||||
body = {
|
||||
"type": "track",
|
||||
"event": event,
|
||||
"properties": properties or {},
|
||||
}
|
||||
# OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
|
||||
# sends) — a flat body is rejected with 400, which this module swallows by design.
|
||||
# The payload field is "name" (ITrackPayload in packages/validation) — "event" is
|
||||
# rejected with a 400 that this module swallows.
|
||||
inner: dict[str, Any] = {"name": event, "properties": properties or {}}
|
||||
if profile_id:
|
||||
body["profileId"] = profile_id
|
||||
inner["profileId"] = profile_id
|
||||
body = {"type": "track", "payload": inner}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
await client.post(
|
||||
@@ -66,7 +67,7 @@ async def identify(profile_id: str, properties: dict[str, Any] | None = None) ->
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
await client.post(
|
||||
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
|
||||
json={"type": "identify", "profileId": profile_id, "properties": properties or {}},
|
||||
json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
|
||||
headers={
|
||||
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
|
||||
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
|
||||
|
||||
@@ -252,6 +252,8 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
||||
"created_at": to_iso(m.created_at),
|
||||
"updated_at": to_iso(m.updated_at),
|
||||
}
|
||||
# Category is a supplier attribute now, not a material one — the key stays
|
||||
# in the payload (null) so the contract shape is unchanged.
|
||||
if "category" in expand:
|
||||
d["category"] = None
|
||||
else:
|
||||
|
||||
@@ -873,7 +873,6 @@ async def quote_stats(
|
||||
project_id: UUID | None = None,
|
||||
quote_date_from=None,
|
||||
quote_date_to=None,
|
||||
category_id: UUID | None = None,
|
||||
) -> dict:
|
||||
conditions = []
|
||||
if supplier_id is not None:
|
||||
@@ -884,8 +883,6 @@ async def quote_stats(
|
||||
conditions.append(Quote.quote_date >= quote_date_from)
|
||||
if quote_date_to is not None:
|
||||
conditions.append(Quote.quote_date <= quote_date_to)
|
||||
if category_id is not None:
|
||||
conditions.append(_quote_has_category(category_id))
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
@@ -917,18 +914,6 @@ async def quote_stats(
|
||||
}
|
||||
|
||||
|
||||
def _quote_has_category(category_id: UUID):
|
||||
"""EXISTS: quote's supplier belongs to category_id."""
|
||||
sub = (
|
||||
select(1)
|
||||
.where(
|
||||
Supplier.id == Quote.supplier_id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
return func.exists(sub)
|
||||
|
||||
|
||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ async def run() -> None:
|
||||
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
|
||||
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
|
||||
"material_attribute_values, materials, brands, warehouses, projects, users, "
|
||||
"category_reference_units, unit_conversions, standard_conversions "
|
||||
"standard_conversions "
|
||||
"RESTART IDENTITY CASCADE"
|
||||
))
|
||||
# Only the 8 canonical topic categories may exist — any leftover slug
|
||||
|
||||
@@ -248,6 +248,9 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
- ./backend/app:/srv/backend/app
|
||||
|
||||
networks:
|
||||
- traefik_network
|
||||
- default
|
||||
|
||||
@@ -23,9 +23,13 @@ ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
||||
# Empty values disable the feature (analytics.ts is a no-op then).
|
||||
ARG VITE_OPENPANEL_URL=""
|
||||
ARG VITE_OPENPANEL_CLIENT_ID=""
|
||||
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
|
||||
# so it needs its own arg — defined here or the value never reaches the bundle.
|
||||
ARG VITE_OPENPANEL_SCRIPT_URL=""
|
||||
ARG VITE_SENTRY_DSN=""
|
||||
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
|
||||
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
|
||||
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
|
||||
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
||||
|
||||
# Source code + build
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
||||
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
|
||||
"short_name": "دفترچه تأمین",
|
||||
"description": "سامانه تدارکات و مدیریت انبار پروژههای ساختمانی",
|
||||
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
|
||||
"short_name": "مدیریت پروژه و تأمین",
|
||||
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
|
||||
"dir": "rtl",
|
||||
"lang": "fa",
|
||||
"start_url": "/",
|
||||
|
||||
+280
-10
@@ -1,5 +1,5 @@
|
||||
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 { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { RequirePmProjects } from "./components/RequirePmProjects";
|
||||
@@ -38,11 +38,14 @@ const WarehousesPage = lazy(() =>
|
||||
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
||||
);
|
||||
const OrganizationPage = lazy(() =>
|
||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||
);
|
||||
const AdminPanelPage = lazy(() =>
|
||||
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
||||
);
|
||||
const RequesterHome = lazy(() =>
|
||||
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
|
||||
);
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
@@ -55,15 +58,251 @@ function Loading() {
|
||||
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { pathname } = useLocation();
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === "supervisor") {
|
||||
// اگر سرپرست پروژهای نداشته باشد، دسترسی مسدود میشود
|
||||
const hasProjects =
|
||||
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||
if (!hasProjects) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنلهای تخصیصی:
|
||||
const panels = user.panels ?? {};
|
||||
const isAdmin = user.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user.role === "pm" || user.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user.role === "pm" || user.role === "supervisor");
|
||||
|
||||
const isProcurementRoute =
|
||||
pathname === "/" ||
|
||||
pathname.startsWith("/wizard") ||
|
||||
pathname.startsWith("/suppliers") ||
|
||||
pathname.startsWith("/projects") ||
|
||||
pathname.startsWith("/units") ||
|
||||
pathname.startsWith("/requester");
|
||||
|
||||
if (isProcurementRoute && !hasProcurement) {
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/inventory") && !hasInventory) {
|
||||
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// صفحهی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
|
||||
// صفحهی اصلی بر اساس لایه پنلها (جایگزین نقشها):
|
||||
function HomeRoute() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
||||
return <DashboardPage />;
|
||||
const panels = user?.panels ?? {};
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
// ۱. اگر پنل تدارکات دارد:
|
||||
if (hasProcurement) {
|
||||
if (panels.procurement === "limited" && !isAdmin) {
|
||||
return <Navigate to="/wizard" replace />;
|
||||
}
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
// ۲. اگر پنل انبار دارد:
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
|
||||
// ۳. اگر پنل PM دارد:
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
|
||||
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
|
||||
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنلهای سامانه</h3>
|
||||
<p className="mt-2 text-xs text-muted leading-6">
|
||||
حساب کاربری شما فعال است اما در حال حاضر به هیچیک از پنلهای سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای تدارکات و پنلهای تدارکات (full یا limited)
|
||||
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const panels = user?.panels ?? {};
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager" || user?.role === "pm");
|
||||
|
||||
if (hasProcurement) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
||||
function PmPanelPage() {
|
||||
return (
|
||||
<iframe
|
||||
src="/pm/"
|
||||
title="مدیریت پروژه (PM)"
|
||||
className="h-dvh w-full border-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای pm/supervisor
|
||||
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const panels = user?.panels ?? {};
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
if (!hasPm) {
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
if (hasProcurement) {
|
||||
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||
}
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
||||
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === "admin";
|
||||
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
if (!allowed) {
|
||||
const panels = user?.panels ?? {};
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
|
||||
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
return <Navigate to="/panel/pm" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
|
||||
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
|
||||
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const allowed =
|
||||
user?.role === "admin" ||
|
||||
user?.role === "supervisor" ||
|
||||
user?.panels?.inventory === "limited";
|
||||
if (!allowed) {
|
||||
return <Navigate to="/inventory" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را میبیند)
|
||||
function RequireWarehouseManage({ 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="/inventory" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -84,10 +323,13 @@ export default function App() {
|
||||
path="/wizard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<WizardPage />
|
||||
<RequireProcurementWrite>
|
||||
<WizardPage />
|
||||
</RequireProcurementWrite>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
|
||||
<Route
|
||||
path="/suppliers"
|
||||
element={
|
||||
@@ -164,7 +406,9 @@ export default function App() {
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<ChangeInventoryPage />
|
||||
<RequireInventoryChange>
|
||||
<ChangeInventoryPage />
|
||||
</RequireInventoryChange>
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
@@ -184,18 +428,24 @@ export default function App() {
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<WarehousesPage />
|
||||
<RequireWarehouseManage>
|
||||
<WarehousesPage />
|
||||
</RequireWarehouseManage>
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/org"
|
||||
element={<Navigate to="/organization" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/organization"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<RequireOrganization>
|
||||
<OrganizationPage />
|
||||
</RequirePmProjects>
|
||||
</RequireOrganization>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -207,6 +457,26 @@ export default function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
|
||||
<Route
|
||||
path="/requester"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequesterHome />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
|
||||
<Route
|
||||
path="/panel/pm"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmPanel>
|
||||
<PmPanelPage />
|
||||
</RequirePmPanel>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,48 +1,84 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
|
||||
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
|
||||
// دقیقاً مثل منوی دفتر: عنوان «پنلها»، ماژول فعلی با استایل فعال (پر)
|
||||
// + «✓ فعلی»، ماژول دیگر بهعنوان سوییچر، و در پایان جداکننده + خروج.
|
||||
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز میشود که همهی
|
||||
// پروژههای تخصیصیافته را نشان میدهد؛ پروژهی فعال با «✓ فعلی» مشخص است و
|
||||
// انتخاب هر پروژه فقط «پروژهی فعال» را عوض میکند (نشست/توکن دستنخورده).
|
||||
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
|
||||
// ۱. مدیریت انبار (/inventory)
|
||||
// ۲. مدیریت تدارکات (/)
|
||||
// ۳. مدیریت پروژه (/panel/pm)
|
||||
// ۴. سازماندهی (/organization)
|
||||
export function AppSwitcherMenu({
|
||||
activeModule,
|
||||
canSwitchToProcurement = true,
|
||||
}: {
|
||||
activeModule: "warehouse" | "procurement";
|
||||
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
|
||||
activeModule: "warehouse" | "procurement" | "organization";
|
||||
canSwitchToProcurement?: boolean;
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
const { pathname } = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setActiveProject = useAuthStore((s) => s.setActiveProject);
|
||||
const panels = user?.panels ?? {};
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
// فقط نقشهای ادمین، مدیر پروژه و مدیر تدارکات به پنل سازماندهی دسترسی دارند
|
||||
const canManageOrg =
|
||||
isAdmin ||
|
||||
user?.role === "procurement_manager" ||
|
||||
user?.role === "pm";
|
||||
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
const isPm = user?.role === "pm";
|
||||
const pmProjects = isPm ? user?.projects ?? [] : [];
|
||||
const projectChildren: MenuItem[] | undefined =
|
||||
activeModule === "warehouse" && isPm
|
||||
? pmProjects.map((p) => ({
|
||||
label: p.name,
|
||||
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض میشود
|
||||
active: p.id === user?.project_id,
|
||||
onClick: () => setActiveProject(p.id),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
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) {
|
||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
||||
}
|
||||
if (hasInventory) {
|
||||
items.push({
|
||||
label: "انبار",
|
||||
to: "/inventory",
|
||||
active: activeModule === "warehouse",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasProcurement && canSwitchToProcurement) {
|
||||
items.push({
|
||||
label: "تدارکات",
|
||||
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
|
||||
active: activeModule === "procurement",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPm) {
|
||||
items.push({
|
||||
label: "مدیریت پروژه",
|
||||
to: "/panel/pm",
|
||||
});
|
||||
}
|
||||
|
||||
if (canManageOrg) {
|
||||
items.push({
|
||||
label: "سازماندهی",
|
||||
to: "/organization",
|
||||
active: activeModule === "organization",
|
||||
});
|
||||
}
|
||||
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "خروج از حساب", onClick: logout });
|
||||
return <PanelMenu title="پنلها" items={items} />;
|
||||
|
||||
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
|
||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 w-full my-2">
|
||||
{/* کادر انتخاب تاریخ */}
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="flex items-start gap-2 w-full my-2">
|
||||
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||
<div className="w-28 shrink-0">
|
||||
<JalaliDatePicker
|
||||
value={milestone.when_iso}
|
||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
||||
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<NumberInput
|
||||
value={milestone.amount}
|
||||
|
||||
@@ -7,9 +7,11 @@ export interface NumberInputProps {
|
||||
onChange: (n: number | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیشفرض: راستچین)
|
||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||
inputMode?: "numeric" | "decimal" | undefined;
|
||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||
maxDecimals?: number; // حداکثر رقم اعشار
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -19,6 +21,8 @@ export function NumberInput({
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
inputClassName,
|
||||
align = "right",
|
||||
showWords = false,
|
||||
inputMode = "numeric",
|
||||
maxDecimals,
|
||||
@@ -36,8 +40,6 @@ export function NumberInput({
|
||||
|
||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||
|
||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
||||
useEffect(() => {
|
||||
const cur = parseNum(text);
|
||||
if (value != null && value !== cur) {
|
||||
@@ -60,19 +62,28 @@ export function NumberInput({
|
||||
|
||||
const n = parseNum(text);
|
||||
|
||||
const alignClass =
|
||||
align === "center"
|
||||
? "!text-center text-center"
|
||||
: align === "left"
|
||||
? "!text-left text-left"
|
||||
: "!text-right text-right";
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className={cn("min-w-0 w-full", className)}>
|
||||
<input
|
||||
type="text"
|
||||
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}
|
||||
placeholder={placeholder}
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
||||
const parsed = parseNum(raw);
|
||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||
if (parsed != null && !trailingSep) {
|
||||
@@ -85,16 +96,11 @@ export function NumberInput({
|
||||
}}
|
||||
onBlur={() => commit(text)}
|
||||
/>
|
||||
{showWords && (
|
||||
<span className={cn("num-pill", n == null && "empty")}>
|
||||
{n != null ? (
|
||||
<>
|
||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
{showWords && n != null && (
|
||||
<div className="mt-1 block text-right text-[11px] text-muted">
|
||||
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
|
||||
<span className="text-muted">({toWords(n)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
|
||||
export interface MenuItem {
|
||||
label?: string;
|
||||
to?: string;
|
||||
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز میشود
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
divider?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
|
||||
</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 ? (
|
||||
<Link
|
||||
key={i}
|
||||
|
||||
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
||||
const setProjects = useAuthStore((s) => s.setProjects);
|
||||
const logout = useLogout();
|
||||
|
||||
const isPm = user?.role === "pm";
|
||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
||||
// پنل انبار limited هم مثل سرپرست: پروژههای فعالش از عضویتها اعتبارسنجی میشود
|
||||
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== "pm") {
|
||||
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||
if (!user || !scoped) {
|
||||
setState("ok");
|
||||
return;
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, user?.role, isDev]);
|
||||
}, [user?.id, user?.role, user?.panels, isDev]);
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronRight, ClipboardList } from "lucide-react";
|
||||
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 {
|
||||
title: string;
|
||||
@@ -18,6 +35,10 @@ interface HeaderProps {
|
||||
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const userSub = userSubtitle(user);
|
||||
const displaySub = sub !== undefined ? sub : userSub;
|
||||
|
||||
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
||||
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به 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="min-w-0">
|
||||
<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 className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
PackageSearch,
|
||||
Scale,
|
||||
Truck,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
|
||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||
];
|
||||
|
||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات (آینهی ناوبری بالای دسکتاپ)
|
||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینهی ناوبری بالای دسکتاپ)
|
||||
const ADMIN_INV_TABS: TabDef[] = [
|
||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
|
||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||
{ to: "/inventory/org", label: "سازماندهی", icon: UsersRound },
|
||||
];
|
||||
|
||||
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
||||
@@ -81,14 +80,24 @@ export function MobileTabBar() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
if (pathname === "/login" || pathname === "/admin") return null;
|
||||
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تببار خودش را دارد
|
||||
if (pathname === "/requester" || pathname === "/panel/pm") return null;
|
||||
// کاربر فقطِ تدارکات-limited: تبهای انبار/دفتر ندارد
|
||||
const panels = useAuthStore.getState().user?.panels ?? {};
|
||||
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
|
||||
// پنل مدیریت پروژه: iframe تمامصفحهی PM است و خودش تببار داخلی دارد
|
||||
if (pathname === "/panel/pm") return null;
|
||||
const isPM = role === "pm";
|
||||
const isSupervisor = role === "supervisor";
|
||||
const isOffice = role === "admin" || role === "procurement_manager";
|
||||
|
||||
if (pathname.startsWith("/inventory")) {
|
||||
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||
return null;
|
||||
}
|
||||
// سرپرست پروژه به بخشهای تدارکات/دفتر دسترسی ندارد
|
||||
if (isSupervisor) return null;
|
||||
if (pathname === "/wizard") return null;
|
||||
const onOfficePage =
|
||||
pathname === "/" ||
|
||||
|
||||
@@ -2,12 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
|
||||
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
||||
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
||||
export { useMaterials, useAddMaterial } from "./useMaterials";
|
||||
export { useProjects, useAddProject } from "./useProjects";
|
||||
export {
|
||||
useUnits,
|
||||
useSaveRefUnit,
|
||||
useSaveConversion,
|
||||
useDeleteConversion,
|
||||
useSaveStandardConversion,
|
||||
useDeleteStandardConversion,
|
||||
} from "./useUnits";
|
||||
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
|
||||
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
||||
|
||||
@@ -21,3 +21,15 @@ export function useAddProject() {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,52 +1,16 @@
|
||||
// ============================================================
|
||||
// hooks واحدها — واحد مرجع، ضرایب دستهای و ثابت
|
||||
// hooks واحدها — تبدیلهای ثابت جهانی (دستهایها حذف شدند)
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types";
|
||||
import type { StandardConversion } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useUnits(): {
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
} {
|
||||
const categoryUnits = useAppStore((s) => s.categoryUnits);
|
||||
const unitConversions = useAppStore((s) => s.unitConversions);
|
||||
export function useUnits(): { standardConversions: StandardConversion[] } {
|
||||
const standardConversions = useAppStore((s) => s.standardConversions);
|
||||
return { categoryUnits, unitConversions, standardConversions };
|
||||
}
|
||||
|
||||
export function useSaveRefUnit() {
|
||||
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
|
||||
onMutate: (cu) => setCategoryUnit(cu),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveConversion() {
|
||||
const setConversion = useAppStore((s) => s.setConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
|
||||
onMutate: (c) => setConversion(c),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversion() {
|
||||
const deleteConversion = useAppStore((s) => s.deleteConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.units.deleteConversion(id),
|
||||
onMutate: (id) => deleteConversion(id),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
return { standardConversions };
|
||||
}
|
||||
|
||||
export function useSaveStandardConversion() {
|
||||
|
||||
@@ -22,8 +22,31 @@ declare global {
|
||||
|
||||
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
|
||||
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
|
||||
const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim();
|
||||
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
|
||||
|
||||
/**
|
||||
* Load the OpenPanel browser script (op1.js) and its queueing shim.
|
||||
*
|
||||
* The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the
|
||||
* script is in flight, so `op("init", …)` can be called immediately and the real
|
||||
* script drains the queue on load. Without it, every `window.op?.()` call here is a
|
||||
* silent no-op — which is what happened before this loader existed.
|
||||
*/
|
||||
function loadOpenPanelScript(src: string): void {
|
||||
if (document.querySelector("script[data-openpanel]")) return;
|
||||
const shim = document.createElement("script");
|
||||
shim.textContent =
|
||||
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();';
|
||||
document.head.appendChild(shim);
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.dataset.openpanel = "1";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
export function initAnalytics(): void {
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
@@ -35,6 +58,7 @@ export function initAnalytics(): void {
|
||||
}
|
||||
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
|
||||
try {
|
||||
if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL);
|
||||
window.op?.("init", {
|
||||
apiUrl: OPENPANEL_URL,
|
||||
clientId: OPENPANEL_CLIENT_ID,
|
||||
|
||||
+66
-117
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import type {
|
||||
Attachment,
|
||||
CategoryUnit,
|
||||
ConfirmedReceipt,
|
||||
InventoryRow,
|
||||
InventoryTransaction,
|
||||
@@ -30,7 +29,6 @@ import type {
|
||||
Store,
|
||||
Supplier,
|
||||
TransactionType,
|
||||
UnitConversion,
|
||||
Warehouse,
|
||||
} from "./types";
|
||||
|
||||
@@ -89,7 +87,7 @@ function handle401(path: string, res: Response): void {
|
||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||
function activeProjectId(): string | null {
|
||||
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 {
|
||||
@@ -322,7 +320,6 @@ interface RawSupplier {
|
||||
phone?: string | null;
|
||||
contact_person?: string | null;
|
||||
tax_id?: string | null;
|
||||
categories?: string[] | null; // متریالهای تأمینشده
|
||||
// v2.6: هر تأمینکننده دقیقاً یک دسته دارد (FK) — بکاند این را میفرستد
|
||||
category_id?: string | null;
|
||||
category?: { id: string; slug: string; name: string } | null;
|
||||
@@ -330,6 +327,12 @@ interface RawSupplier {
|
||||
interface RawProject {
|
||||
id: 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 {
|
||||
attribute_key: string;
|
||||
@@ -402,21 +405,6 @@ interface RawQuote {
|
||||
line_items?: RawLineItem[] | null;
|
||||
termsheets?: RawTermsheet[] | null;
|
||||
}
|
||||
interface RawCategoryUnit {
|
||||
id: string;
|
||||
category_id: string;
|
||||
ref_unit: string;
|
||||
category?: RawCategory | null;
|
||||
}
|
||||
interface RawUnitConversion {
|
||||
id: string;
|
||||
category_id: string;
|
||||
brand?: string | null;
|
||||
from_unit: string;
|
||||
to_unit: string;
|
||||
factor: number;
|
||||
category?: RawCategory | null;
|
||||
}
|
||||
interface RawStandardConversion {
|
||||
id: string;
|
||||
from_unit: string;
|
||||
@@ -562,7 +550,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
||||
topic:
|
||||
raw.topic ||
|
||||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
||||
category: raw.category?.name || "",
|
||||
brand: raw.brands?.[0]?.name || "",
|
||||
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
||||
specs: (raw.attributes || [])
|
||||
@@ -643,11 +630,13 @@ function adaptQuote(raw: RawQuote): Quote {
|
||||
}
|
||||
|
||||
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
||||
// هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل category
|
||||
// برمیگرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
|
||||
const own = raw.category?.name ? [raw.category.name] : [];
|
||||
return {
|
||||
id: raw.id,
|
||||
topic,
|
||||
// دستههای ذخیرهشده روی تأمینکننده (بکاند) اولویت دارند؛ وگرنه از quotes استنتاج میشود
|
||||
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
|
||||
categories: own.length ? own : categories,
|
||||
name: raw.name,
|
||||
location: raw.location || "",
|
||||
phone: raw.phone || "",
|
||||
@@ -665,54 +654,29 @@ function deriveSupplierTopics(
|
||||
materialsById: Map<string, Material>,
|
||||
): Supplier[] {
|
||||
const topics = new Map<string, Set<string>>();
|
||||
const cats = new Map<string, Set<string>>();
|
||||
for (const q of quotes) {
|
||||
for (const li of q.line_items) {
|
||||
const m = materialsById.get(li.material_id);
|
||||
if (!m) continue;
|
||||
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
||||
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
|
||||
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
|
||||
if (m.category) cats.get(q.supplier_id)!.add(m.category);
|
||||
}
|
||||
}
|
||||
return rawSuppliers.map((s) => {
|
||||
const t = topics.get(s.id);
|
||||
const c = cats.get(s.id);
|
||||
const derivedTopic = t && t.size ? [...t][0] : "";
|
||||
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
|
||||
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
||||
if (!derivedTopic && !derivedCats.length) {
|
||||
if (!derivedTopic) {
|
||||
const reg = supplierTopicRegistry.get(s.id);
|
||||
if (reg) {
|
||||
return adaptSupplier(s, reg.topic, reg.categories);
|
||||
}
|
||||
if (reg) return adaptSupplier(s, reg.topic, reg.categories);
|
||||
}
|
||||
// v2.7: متریالها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
|
||||
// دستهای نمیدهد و صفحهی تأمینکنندهها بینام میشد. دستهٔ ثبتشده روی
|
||||
// خودِ تأمینکننده (FK بکاند) منبع اصلی است؛ نامش با نام تاپیکها یکی است.
|
||||
// دستهٔ ثبتشده روی خودِ تأمینکننده (FK بکاند) منبع اصلی است و نامش با نام
|
||||
// تاپیکها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج میشود.
|
||||
const backendCat = s.category?.name || "";
|
||||
return adaptSupplier(
|
||||
s,
|
||||
derivedTopic || backendCat,
|
||||
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
|
||||
);
|
||||
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
|
||||
});
|
||||
}
|
||||
|
||||
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
|
||||
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
|
||||
}
|
||||
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
|
||||
return {
|
||||
id: raw.id,
|
||||
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
|
||||
brand: raw.brand || "",
|
||||
from_unit: raw.from_unit,
|
||||
to_unit: raw.to_unit,
|
||||
factor: raw.factor,
|
||||
};
|
||||
}
|
||||
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
|
||||
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
|
||||
}
|
||||
@@ -830,16 +794,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
|
||||
export const api = {
|
||||
// ---------- کل store (یک بار در ریشه) ----------
|
||||
async listAll(): Promise<Store> {
|
||||
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] =
|
||||
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
|
||||
await Promise.all([
|
||||
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
|
||||
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
|
||||
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
|
||||
getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
|
||||
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
|
||||
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
|
||||
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
|
||||
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
|
||||
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
|
||||
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
|
||||
]);
|
||||
|
||||
@@ -854,8 +816,6 @@ export const api = {
|
||||
materials,
|
||||
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
|
||||
quotes,
|
||||
categoryUnits: cruRes.map(adaptCategoryUnit),
|
||||
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
|
||||
standardConversions: scuRes.map(adaptStandardConversion),
|
||||
topics: buildTopics(categoriesRes),
|
||||
};
|
||||
@@ -1130,7 +1090,7 @@ export const api = {
|
||||
|
||||
materials: {
|
||||
list: async (): Promise<Material[]> => {
|
||||
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
|
||||
const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
|
||||
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
|
||||
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
|
||||
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
|
||||
@@ -1149,7 +1109,7 @@ export const api = {
|
||||
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
||||
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
||||
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
||||
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||
const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
||||
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -1181,55 +1141,40 @@ export const api = {
|
||||
projects: {
|
||||
list: async (): Promise<Project[]> => {
|
||||
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> => {
|
||||
const raw = await postData<RawProject>("/projects", { name: p.name });
|
||||
return { id: raw.id, name: raw.name };
|
||||
const raw = await postData<RawProject>("/projects", {
|
||||
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}`);
|
||||
},
|
||||
},
|
||||
|
||||
units: {
|
||||
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
|
||||
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
|
||||
return raw.map(adaptCategoryUnit);
|
||||
},
|
||||
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
|
||||
const lookup = await fetchLookup();
|
||||
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
|
||||
if (!categoryId) throw new Error(`دستهبندی «${cu.category}» یافت نشد`);
|
||||
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
|
||||
},
|
||||
getConversions: async (): Promise<UnitConversion[]> => {
|
||||
const lookup = await fetchLookup();
|
||||
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
|
||||
return raw.map((uc) => adaptUnitConversion(uc, lookup));
|
||||
},
|
||||
saveConversion: async (c: UnitConversion): Promise<void> => {
|
||||
const lookup = await fetchLookup();
|
||||
const categoryId = lookup.categoryNameToId.get(norm(c.category));
|
||||
if (!categoryId) throw new Error(`دستهبندی «${c.category}» یافت نشد`);
|
||||
const body = {
|
||||
category_id: categoryId,
|
||||
brand: c.brand || null,
|
||||
from_unit: c.from_unit,
|
||||
to_unit: c.to_unit,
|
||||
factor: c.factor,
|
||||
};
|
||||
if (looksLikeUuid(c.id)) {
|
||||
await patchData(`/unit-conversions/${c.id}`, {
|
||||
brand: c.brand || null,
|
||||
from_unit: c.from_unit,
|
||||
to_unit: c.to_unit,
|
||||
factor: c.factor,
|
||||
});
|
||||
} else {
|
||||
await postData("/unit-conversions", body);
|
||||
}
|
||||
},
|
||||
deleteConversion: async (id: string): Promise<void> => {
|
||||
await deleteData(`/unit-conversions/${id}`);
|
||||
},
|
||||
getStandardConversions: async (): Promise<StandardConversion[]> => {
|
||||
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
|
||||
return raw.map(adaptStandardConversion);
|
||||
@@ -1615,11 +1560,16 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
casdoor_id: string;
|
||||
username?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||
role_label: string | null;
|
||||
// لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', ...}
|
||||
panels?: Record<string, string>;
|
||||
// عضویت M:N پروژهای — محدوده داده سطح limited
|
||||
member_project_ids?: string[];
|
||||
project_id: string | null;
|
||||
project_name: string | null;
|
||||
// 1:N — every project this PM manages (oldest first).
|
||||
@@ -1643,10 +1593,11 @@ export const apiV26 = {
|
||||
user: {
|
||||
id: string;
|
||||
casdoor_id: string;
|
||||
username?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||
role_label: string | null;
|
||||
project_id: string | null;
|
||||
project_name: string | null;
|
||||
@@ -1660,6 +1611,7 @@ export const apiV26 = {
|
||||
expires_in: res.data.expires_in,
|
||||
user: {
|
||||
...u,
|
||||
username: u.username || u.casdoor_id,
|
||||
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
||||
},
|
||||
};
|
||||
@@ -1679,14 +1631,15 @@ export const apiV26 = {
|
||||
users: {
|
||||
me: async (): Promise<import("../store/auth").AuthUser> => {
|
||||
const data = await getData<{
|
||||
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
|
||||
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
|
||||
project_id: string | null; project_name: string | null;
|
||||
projects?: { id: string; name: string }[];
|
||||
}>("/users/me");
|
||||
return {
|
||||
id: data.id,
|
||||
casdoor_id: data.casdoor_id,
|
||||
username: data.username || data.casdoor_id,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
phone: data.phone,
|
||||
@@ -1710,8 +1663,13 @@ export const apiV26 = {
|
||||
first_name: string; last_name?: string; phone?: string | null;
|
||||
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
||||
}): 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 }),
|
||||
// لایه پنلها — تخصیص پنل + سطح دسترسی و عضویت پروژهای (فقط ادمین)
|
||||
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
|
||||
putData<UserRow>(`/users/${id}/panels`, { panels }),
|
||||
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
|
||||
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||
@@ -1775,15 +1733,6 @@ export const apiV26 = {
|
||||
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
|
||||
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
|
||||
},
|
||||
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
|
||||
const params: Record<string, string> = { category_id };
|
||||
if (warehouse_id) params.warehouse_id = warehouse_id;
|
||||
const pid = activeProjectId();
|
||||
if (pid) params.project_id = pid;
|
||||
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
|
||||
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
pm: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
|
||||
// ============================================================
|
||||
|
||||
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types";
|
||||
import type { LineItem, Material, Quote, Store, Supplier } from "./types";
|
||||
import { normalize } from "./normalize";
|
||||
import { groupCommas } from "./numutil";
|
||||
import { fmtDate } from "./formatters";
|
||||
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
|
||||
id: "",
|
||||
name: "",
|
||||
topic: "",
|
||||
category: "؟",
|
||||
brand: "",
|
||||
specs: [],
|
||||
base_unit: "",
|
||||
@@ -46,7 +45,7 @@ export function specsLabel(specs: string[]): string {
|
||||
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||
*/
|
||||
export function formatMaterialTitle(
|
||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||
m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
|
||||
extraSpecs?: string[],
|
||||
): string {
|
||||
if (!m) return "کالا";
|
||||
@@ -63,7 +62,7 @@ export function formatMaterialTitle(
|
||||
const normName = normalize(rawName);
|
||||
if (normName.startsWith(normBrand)) {
|
||||
const rest = rawName.slice(rawBrand.length).trim();
|
||||
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||
rawName = rest.replace(/^[.\s·-]+/, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +74,7 @@ export function formatMaterialTitle(
|
||||
if (rawName) {
|
||||
parts.push(rawName);
|
||||
} else if (!rawBrand) {
|
||||
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
|
||||
parts.push("کالا");
|
||||
}
|
||||
|
||||
// اضافه کردن مشخصهها بدون تکرار
|
||||
@@ -95,10 +94,67 @@ export function formatMaterialTitle(
|
||||
return parts.join(" . ");
|
||||
}
|
||||
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
|
||||
return formatMaterialTitle(m);
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||
// name را به شکل «برند + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا نامِ
|
||||
// پایه باقی بماند.
|
||||
export function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||
const fallback = (name || "").trim();
|
||||
if (!fallback) return "";
|
||||
let base = fallback;
|
||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||
const parts = [brand, ...specs]
|
||||
.map((p) => (p || "").trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const p of parts) {
|
||||
if (!base.includes(p)) continue;
|
||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||
if (!next) continue;
|
||||
base = next;
|
||||
}
|
||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||
if (parts.some((p) => p === base)) return fallback;
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
|
||||
*
|
||||
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
|
||||
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||
* برند و مشخصهی یکسان (مثل دو چمن مصنوعیِ همبرند) را میگیرد. نامِ خالی یعنی
|
||||
* هویت نامعلوم — در آن حالت تطبیق نمیدهیم و متریالِ تازه ساخته میشود.
|
||||
*/
|
||||
export function matchMaterial(
|
||||
materials: Material[],
|
||||
name: string,
|
||||
brand: string,
|
||||
specs: string[],
|
||||
): Material | undefined {
|
||||
const wantName = normalize(name);
|
||||
if (!wantName) return undefined;
|
||||
const wantBrand = normalize(brand);
|
||||
const wantSpecs = specsKey(specs);
|
||||
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
|
||||
const base = normalize(materialBaseName(name, brand, specs));
|
||||
return (
|
||||
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
|
||||
materials.find(
|
||||
(m) =>
|
||||
base &&
|
||||
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
|
||||
sameIdentity(m),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||
export function itemTermsheetIds(li: LineItem): string[] {
|
||||
if (li.termsheet_ids) return li.termsheet_ids;
|
||||
@@ -158,54 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||
|
||||
// واحد مرجع یک دسته
|
||||
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
|
||||
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
|
||||
return cu ? cu.ref_unit : "";
|
||||
}
|
||||
|
||||
// ضریب تبدیل با اولویت: دستهای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
|
||||
export function findConversion(
|
||||
store: Pick<Store, "unitConversions" | "standardConversions">,
|
||||
category: string,
|
||||
brand: string,
|
||||
fromUnit: string,
|
||||
toUnit: string,
|
||||
): UnitConversion | StandardConversion | null {
|
||||
const list = (store.unitConversions || []).filter(
|
||||
(c) =>
|
||||
normalize(c.category) === normalize(category) &&
|
||||
normalize(c.from_unit) === normalize(fromUnit) &&
|
||||
normalize(c.to_unit) === normalize(toUnit) &&
|
||||
(!c.brand || normalize(c.brand) === normalize(brand)),
|
||||
);
|
||||
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
|
||||
if (list[0]) return list[0];
|
||||
return (
|
||||
(store.standardConversions || []).find(
|
||||
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
|
||||
export function refConverted(
|
||||
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
|
||||
li: LineItem,
|
||||
): { qty: number; unit: string; perRef: number | null } | null {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const ref = refUnitForCategory(store, m.category);
|
||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
||||
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
||||
if (!conv) return null;
|
||||
const qty = (li.quantity || 0) * conv.factor;
|
||||
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
||||
const perRef = qty > 0 ? Math.round(total / qty) : null;
|
||||
return { qty, unit: ref, perRef };
|
||||
}
|
||||
|
||||
// واحدهای ناشناختهی استفادهشده در یک quote → لینک به داشبورد واحدها
|
||||
// واحدها دیگر hardcode نیستند — همه از بکاند میآیند؛ هشداری نمایش داده نمیشود
|
||||
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
|
||||
@@ -275,22 +283,30 @@ export function termsheetSummary(store: Store, q: Quote): { title: string; sub:
|
||||
// فیلترهای دفتر
|
||||
export function filterQuotes(
|
||||
store: Store,
|
||||
filters: { category: string; supplier: string; status: string; search: string },
|
||||
filters: { supplier: string; status: string; search: string },
|
||||
): Quote[] {
|
||||
const catVal = filters.category.trim();
|
||||
const supVal = filters.supplier.trim();
|
||||
const stVal = filters.status.trim();
|
||||
const q = filters.search.trim().toLowerCase();
|
||||
|
||||
const STATUS_BY_LABEL: Record<string, string> = {
|
||||
"پیشنهاد شده": "offered",
|
||||
"در انتظار تایید": "offered",
|
||||
"در انتظار تأیید": "offered",
|
||||
"پذیرفته شده": "accepted",
|
||||
"در حال خرید": "accepted",
|
||||
"رسیده": "arrived",
|
||||
"تکمیل شده": "arrived",
|
||||
"باز": "offered",
|
||||
"پذیرفته": "accepted",
|
||||
"تکمیل": "arrived",
|
||||
"offered": "offered",
|
||||
"accepted": "accepted",
|
||||
"arrived": "arrived",
|
||||
"rejected": "rejected",
|
||||
"رد شده": "rejected",
|
||||
"expired": "expired",
|
||||
"منقضی شده": "expired",
|
||||
};
|
||||
const stKey = STATUS_BY_LABEL[stVal] || stVal;
|
||||
|
||||
@@ -300,12 +316,12 @@ export function filterQuotes(
|
||||
if (stKey && quote.status !== stKey) return false;
|
||||
|
||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
||||
|
||||
if (q) {
|
||||
const projectsText = `${(quote.projects || []).join(" ")} ${quote.project || ""}`;
|
||||
const hay =
|
||||
`${supObj.name} ${quote.project || ""} ` +
|
||||
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
`${quote.id} PR-${quote.id} ${supObj.name} ${projectsText} ` +
|
||||
matInfo.map((m) => `${m.name} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
if (!hay.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -20,9 +20,8 @@ export interface Supplier {
|
||||
// ماده/کالا
|
||||
export interface Material {
|
||||
id: string; // "m1", "m2", ...
|
||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + مشخصه)
|
||||
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
||||
topic: string;
|
||||
category: string; // اختیاری در بکاند؛ همیشه string در فرانت (default: "")
|
||||
brand: string; // مثلاً "ذوب آهن"
|
||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||
base_unit: string; // واحد پایه
|
||||
@@ -32,6 +31,12 @@ export interface Material {
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
project_manager?: string;
|
||||
description?: string;
|
||||
start_date?: string | null;
|
||||
status?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
// مرحلهی پرداخت
|
||||
@@ -97,23 +102,6 @@ export interface Quote {
|
||||
termsheets: Termsheet[];
|
||||
}
|
||||
|
||||
// واحد مرجع هر دسته
|
||||
export interface CategoryUnit {
|
||||
id: string;
|
||||
category: string;
|
||||
ref_unit: string;
|
||||
}
|
||||
|
||||
// ضریب تبدیل (دستهای)
|
||||
export interface UnitConversion {
|
||||
id: string;
|
||||
category: string;
|
||||
brand: string; // خالی = همه برندها
|
||||
from_unit: string;
|
||||
to_unit: string;
|
||||
factor: number; // 1 from_unit = factor * to_unit
|
||||
}
|
||||
|
||||
// ضریب تبدیل استاندارد (جهانی)
|
||||
export interface StandardConversion {
|
||||
id: string;
|
||||
@@ -128,8 +116,6 @@ export interface Store {
|
||||
materials: Material[];
|
||||
projects: Project[];
|
||||
quotes: Quote[];
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
topics: Topic[]; // دستهبندیهای ۸گانه از /material-categories
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
pm: "مدیر پروژه",
|
||||
procurement_manager: "مدیر تدارکات",
|
||||
admin: "ادمین",
|
||||
supervisor: "سرپرست پروژه",
|
||||
};
|
||||
|
||||
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از 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) {
|
||||
return (
|
||||
<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">شناسه Casdoor</th>
|
||||
<th className="py-2 pe-3">نقش فعلی</th>
|
||||
<th className="py-2">مدیریت مدیر تدارکات</th>
|
||||
<th className="py-2">مدیریت نقشها</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
const isPMgr = u.role === "procurement_manager";
|
||||
const isSup = u.role === "supervisor";
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-line/50">
|
||||
<td className="py-2 pe-3 font-semibold">
|
||||
@@ -147,17 +169,30 @@ export function AdminPanel() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<button
|
||||
disabled={busyId === u.id}
|
||||
onClick={() => void toggleProcurementManager(u)}
|
||||
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||
isPMgr
|
||||
? "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 ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
disabled={busyId === u.id}
|
||||
onClick={() => void toggleProcurementManager(u)}
|
||||
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||
isPMgr
|
||||
? "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 ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||
</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>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -77,14 +77,35 @@ export function LoginPage() {
|
||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||
return;
|
||||
}
|
||||
const hasProjects =
|
||||
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||
if (user.role === "supervisor" && !hasProjects) {
|
||||
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
||||
return;
|
||||
}
|
||||
setToken(access_token);
|
||||
setUser(user);
|
||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
||||
track("login_success", { role: user.role });
|
||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
||||
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||
const panels = user.panels ?? {};
|
||||
if (panels.procurement === "full") {
|
||||
navigate("/");
|
||||
} else if (panels.procurement === "limited") {
|
||||
navigate("/requester");
|
||||
} else if (panels.inventory) {
|
||||
navigate("/inventory");
|
||||
} else if (panels.pm) {
|
||||
navigate("/panel/pm");
|
||||
} else if (user.role === "supervisor") {
|
||||
navigate("/panel/pm");
|
||||
} else if (user.role === "pm") {
|
||||
navigate("/inventory");
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||
} finally {
|
||||
@@ -273,10 +294,21 @@ export function LoginPage() {
|
||||
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
|
||||
<p className="mt-1 text-[12.5px] leading-6 text-muted">
|
||||
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
|
||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
|
||||
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
|
||||
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
|
||||
</p>
|
||||
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
|
||||
مدیریت پروژه
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
|
||||
تدارکات
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
|
||||
انبارداری
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -235,9 +235,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
{/* هدر ثابت و همتراز */}
|
||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||
<DialogHeader className="p-0 text-right">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
پذیرش پیشنهاد — {sup.name}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
پذیرش پیشنهاد — {sup.name}
|
||||
</DialogTitle>
|
||||
<span className="font-mono text-xs font-bold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-lg border border-line shrink-0 select-all">
|
||||
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<p className="text-[13px] text-muted mt-1 text-right">
|
||||
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
|
||||
|
||||
@@ -18,17 +18,21 @@ import { LedgerTable } from "./LedgerTable";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { AcceptModal } from "./AcceptModal";
|
||||
import { EditModal } from "./EditModal";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
||||
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
||||
|
||||
export function DashboardPage() {
|
||||
useStoreData();
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const { standardConversions } = useUnits();
|
||||
|
||||
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
||||
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
||||
@@ -40,8 +44,8 @@ export function DashboardPage() {
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
|
||||
const store = useMemo(
|
||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
||||
() => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
|
||||
[quotes, suppliers, materials, projects, standardConversions, topics],
|
||||
);
|
||||
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at)
|
||||
@@ -80,13 +84,12 @@ export function DashboardPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20 md:pb-6">
|
||||
<Header
|
||||
title="دفتر پیشنهادهای خرید"
|
||||
title="مدیریت تدارکات"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
actions={
|
||||
@@ -115,33 +118,37 @@ export function DashboardPage() {
|
||||
</Link>
|
||||
|
||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>افزودن پیشنهاد جدید</span>
|
||||
</Link>
|
||||
{canWrite && (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>افزودن پیشنهاد جدید</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||
<StatsBar
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
arrived={stats.arrived}
|
||||
activeFilter={activeStatusFilter}
|
||||
onFilterChange={handleStatusFilterChange}
|
||||
/>
|
||||
<div className="hidden md:block">
|
||||
<StatsBar
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
arrived={stats.arrived}
|
||||
activeFilter={activeStatusFilter}
|
||||
onFilterChange={handleStatusFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<FilterToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
categories={categoryOptions}
|
||||
suppliers={supplierOptions}
|
||||
sortDir={sortDir}
|
||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||
stats={stats}
|
||||
/>
|
||||
<LedgerTable
|
||||
store={store}
|
||||
@@ -150,17 +157,20 @@ export function DashboardPage() {
|
||||
onToggleExpand={toggleExpand}
|
||||
onAccept={setAcceptId}
|
||||
onEdit={setEditId}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</PageLayout>
|
||||
|
||||
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
||||
<Link
|
||||
to="/wizard"
|
||||
aria-label="افزودن پیشنهاد جدید"
|
||||
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus className="h-7 w-7" />
|
||||
</Link>
|
||||
{canWrite && (
|
||||
<Link
|
||||
to="/wizard"
|
||||
aria-label="افزودن پیشنهاد جدید"
|
||||
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus className="h-7 w-7" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||
|
||||
@@ -14,17 +14,16 @@ import {
|
||||
useAddSupplier,
|
||||
useAddMaterial,
|
||||
} from "../../hooks";
|
||||
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -87,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||
@@ -154,7 +154,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return {
|
||||
key: li.id,
|
||||
origLi: li,
|
||||
cat: m.category || "",
|
||||
cat: m.name || "",
|
||||
brand: m.brand || "",
|
||||
specs: materialSpecs(m),
|
||||
qty: li.quantity,
|
||||
@@ -190,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
void api.files
|
||||
.list(quoteId, "proforma")
|
||||
.then((list) => alive && setProformas(list))
|
||||
.catch(() => alive && setProformas([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [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 orig = q.termsheets.find((t) => t.id === card.domId);
|
||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
||||
const getCoveredIds = (card: EditTsCard) => {
|
||||
const cardId = card.domId || card.key;
|
||||
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>) => {
|
||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addItemRow = () => {
|
||||
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
|
||||
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
|
||||
setItemRows((rows) => [
|
||||
...rows,
|
||||
{
|
||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
||||
key: newKey,
|
||||
cat: "",
|
||||
brand: "",
|
||||
specs: [],
|
||||
@@ -218,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
unit: "",
|
||||
price: null,
|
||||
total: null,
|
||||
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
||||
tsDomIds: defaultTsId ? [defaultTsId] : [],
|
||||
acceptedTsId: null,
|
||||
rejected: false,
|
||||
restore: true,
|
||||
@@ -233,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const addTsCard = () => {
|
||||
setTsCards((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) => {
|
||||
if (tsCards.length <= 1) {
|
||||
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
|
||||
return;
|
||||
}
|
||||
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 () => {
|
||||
@@ -265,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
next.projects = [...selectedProjects];
|
||||
next.notes = notes.trim();
|
||||
next.invoices = invoices;
|
||||
next.proformas = proformas;
|
||||
|
||||
const tsIdMap: Record<string, string> = {};
|
||||
const newTermsheets: Termsheet[] = [];
|
||||
@@ -275,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
? domId
|
||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||
tsIdMap[domId] = realId;
|
||||
tsIdMap[card.key] = realId;
|
||||
const orig = next.termsheets.find((t) => t.id === realId);
|
||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||
const st = orig?.status || "offered";
|
||||
@@ -294,12 +350,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const total = row.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
let material = materialsCopy.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
@@ -308,7 +359,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
id: nextId("m", materialsCopy),
|
||||
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
@@ -326,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
if (!rejected) {
|
||||
row.tsDomIds.forEach((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) {
|
||||
termsheet_ids.push(next.termsheets[0].id);
|
||||
@@ -363,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
const referencedTs = new Set<string>();
|
||||
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) => {
|
||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||
li.accepted_ts_id = null;
|
||||
@@ -410,9 +460,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<>
|
||||
<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">
|
||||
{/* هدر سفید و تمیز */}
|
||||
{/* هدر */}
|
||||
<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>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||
{q && (
|
||||
<span className="font-mono text-xs font-bold text-gray-700 bg-gray-100 px-2.5 py-1 rounded-lg border border-gray-200 shrink-0 select-all">
|
||||
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
||||
</div>
|
||||
@@ -425,7 +482,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -496,12 +553,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
||||
{/* محتوا */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||
{/* تب ۱: اقلام و مشخصات */}
|
||||
{/* تب ۱: اقلام */}
|
||||
{activeTab === "items" && (
|
||||
<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="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
@@ -540,7 +596,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* لیست اقلام */}
|
||||
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||
@@ -577,23 +633,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
|
||||
{/* ردیف ۱: متریال و برند */}
|
||||
<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-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 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="نام متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<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
|
||||
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="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
@@ -601,65 +658,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-gray-600">واحد</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 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 className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="انتخاب مشخصات..."
|
||||
className="min-w-0"
|
||||
/>
|
||||
</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
|
||||
disabled={hasReceipts}
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
/>
|
||||
{/* بخش مقادیر و محاسبات مالی */}
|
||||
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 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-gray-600 text-right">تعداد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="مقدار"
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<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">
|
||||
<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">
|
||||
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||
</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
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}
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</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
|
||||
disabled={hasReceipts}
|
||||
value={row.total}
|
||||
@@ -670,14 +737,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
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 className="pt-2 border-t border-gray-100">
|
||||
{row.rejected ? (
|
||||
{row.rejected && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -687,44 +755,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
/>
|
||||
بازگرداندن این قلم به معامله
|
||||
</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>
|
||||
))}
|
||||
@@ -745,11 +777,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* تب ۲: شرایط پرداخت */}
|
||||
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
|
||||
{activeTab === "terms" && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||
<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 && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -766,70 +803,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||
{faNum(idx + 1)}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
{!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>
|
||||
index={idx + 1}
|
||||
milestones={card.milestones}
|
||||
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
|
||||
items={itemOptions}
|
||||
coveredIds={getCoveredIds(card)}
|
||||
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
|
||||
onRemove={() => removeTsCard(card.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -895,11 +878,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* فوتر سفید، محکم و چسبان */}
|
||||
{/* فوتر */}
|
||||
<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" ? (
|
||||
<Button
|
||||
@@ -928,7 +930,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* مودال تایید حذف سفید */}
|
||||
{/* مودال تایید حذف */}
|
||||
<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">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||
import { Search, SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface Filters {
|
||||
category: string;
|
||||
supplier: string;
|
||||
status: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
// گزینههای استاندارد و یکپارچه وضعیت هماهنگ با کارتهای بالای صفحه
|
||||
// گزینههای استاندارد وضعیت برای دراپداون فیلترهای پیشرفته
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: "offered", label: "پیشنهاد باز" },
|
||||
{ id: "accepted", label: "پذیرفتهشده" },
|
||||
{ id: "arrived", label: "تحویلشده" },
|
||||
{ id: "offered", label: "در انتظار تایید" },
|
||||
{ id: "accepted", label: "در حال خرید" },
|
||||
{ id: "arrived", label: "تکمیل شده" },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
offered: "پیشنهاد باز",
|
||||
accepted: "پذیرفتهشده",
|
||||
arrived: "تحویلشده",
|
||||
offered: "در انتظار تایید",
|
||||
accepted: "در حال خرید",
|
||||
arrived: "تکمیل شده",
|
||||
};
|
||||
|
||||
const STATUS_TO_KEY: Record<string, string> = {
|
||||
"در انتظار تایید": "offered",
|
||||
"در انتظار تأیید": "offered",
|
||||
"پیشنهاد باز": "offered",
|
||||
"پذیرفتهشده": "accepted",
|
||||
"تحویلشده": "arrived",
|
||||
"پیشنهاد شده": "offered",
|
||||
"در حال خرید": "accepted",
|
||||
"پذیرفتهشده": "accepted",
|
||||
"پذیرفته شده": "accepted",
|
||||
"تکمیل شده": "arrived",
|
||||
"تحویلشده": "arrived",
|
||||
"رسیده": "arrived",
|
||||
"باز": "offered",
|
||||
"پذیرفته": "accepted",
|
||||
"تحویل": "arrived",
|
||||
"تکمیل": "arrived",
|
||||
offered: "offered",
|
||||
accepted: "accepted",
|
||||
arrived: "arrived",
|
||||
@@ -40,110 +45,191 @@ const STATUS_TO_KEY: Record<string, string> = {
|
||||
export interface FilterToolbarProps {
|
||||
filters: Filters;
|
||||
onChange: (f: Filters) => void;
|
||||
categories: string[];
|
||||
suppliers: { id: string; name: string }[];
|
||||
sortDir: "desc" | "asc";
|
||||
onToggleSort: () => void;
|
||||
stats?: { total: number; offered: number; accepted: number; arrived: number };
|
||||
}
|
||||
|
||||
export function FilterToolbar({
|
||||
filters,
|
||||
onChange,
|
||||
categories,
|
||||
suppliers,
|
||||
sortDir,
|
||||
onToggleSort,
|
||||
stats,
|
||||
}: FilterToolbarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||
|
||||
// شمارش تعداد فیلترهای فعال به جز سرچ
|
||||
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length;
|
||||
// شمارش تعداد فیلترهای فعال به جز سرچ و تب وضعیت
|
||||
const activeFilterCount = [filters.supplier].filter(Boolean).length;
|
||||
|
||||
const clearFilters = () => {
|
||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
||||
const clearSupplierFilter = () => {
|
||||
set({ supplier: "" });
|
||||
};
|
||||
|
||||
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
||||
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
|
||||
const clearAllFilters = () => {
|
||||
onChange({ supplier: "", status: "", search: "" });
|
||||
};
|
||||
|
||||
const normalizedStatus = STATUS_TO_KEY[filters.status] ?? filters.status ?? "";
|
||||
const statusDisplayValue = STATUS_MAP[normalizedStatus] || filters.status;
|
||||
|
||||
const handleStatusChange = (val: string) => {
|
||||
const key = STATUS_TO_KEY[val] || val;
|
||||
set({ status: key });
|
||||
};
|
||||
|
||||
const STATUS_TABS: {
|
||||
key: string;
|
||||
label: string;
|
||||
countKey?: keyof NonNullable<FilterToolbarProps["stats"]>;
|
||||
}[] = [
|
||||
{ key: "", label: "همه", countKey: "total" },
|
||||
{ key: "offered", label: "در انتظار تایید", countKey: "offered" },
|
||||
{ key: "accepted", label: "در حال خرید", countKey: "accepted" },
|
||||
{ key: "arrived", label: "تکمیل شده", countKey: "arrived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
{/* اینپوت جستجوی متنی */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* ===== ردیف جستجو و کنترلهای فیلتر (تکردیف افقی فشرده) ===== */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* فیلد جستجوی متنی پروژه / کالا / تأمینکننده */}
|
||||
<div className="relative flex-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted">
|
||||
<Search className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
className="h-10 w-full rounded-xl border border-line bg-panel px-3 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="جستجو در نام، برند، پروژه..."
|
||||
className="h-10 w-full rounded-xl border border-line bg-panel pr-9 pl-8 text-xs sm:text-sm text-ink shadow-2xs placeholder:text-muted focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/25 transition-all"
|
||||
placeholder="جستجو در پروژه، کالا، تأمینکننده..."
|
||||
value={filters.search}
|
||||
onChange={(e) => set({ search: e.target.value })}
|
||||
/>
|
||||
{filters.search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set({ search: "" })}
|
||||
className="absolute inset-y-0 left-0 flex items-center pl-2.5 text-muted hover:text-ink cursor-pointer"
|
||||
aria-label="پاک کردن جستجو"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمه فیلترها با بج تعداد انتخابشده */}
|
||||
{/* دکمه باز کردن فیلترهای پیشرفته (تأمینکننده و ...) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-sm transition-colors ${
|
||||
className={cn(
|
||||
"relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-2xs transition-all shrink-0 cursor-pointer",
|
||||
activeFilterCount > 0
|
||||
? "border-accent bg-accent-bg text-accent"
|
||||
: "border-line bg-panel text-ink hover:bg-slate-bg"
|
||||
}`}
|
||||
title="فیلترها"
|
||||
? "border-accent bg-accent-bg text-accent-dark ring-1 ring-accent/30"
|
||||
: "border-line bg-panel text-ink hover:bg-slate-50 dark:hover:bg-zinc-800",
|
||||
)}
|
||||
title="فیلترهای پیشرفته"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>فیلتر</span>
|
||||
<span className="hidden xs:inline">فیلترها</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-[11px] font-bold text-white">
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 text-[10.5px] font-bold text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* دکمه تغییر جهت سورت */}
|
||||
{/* دکمه تغییر جهت مرتبسازی */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSort}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg"
|
||||
title={sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-2xs hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
|
||||
title={sortDir === "desc" ? "مرتبسازی: جدیدترین اول" : "مرتبسازی: قدیمیترین اول"}
|
||||
aria-label={sortDir === "desc" ? "مرتبسازی جدیدترین" : "مرتبسازی قدیمیترین"}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4 text-accent" />
|
||||
<ArrowUpDown className={cn("h-4 w-4 text-accent transition-transform", sortDir === "asc" && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* باتمشیت کشویی فیلترها برای موبایل */}
|
||||
{/* ===== تبهای اسکرولافقی چیپ وضعیت (Filter Chips) ===== */}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto py-0.5 no-scrollbar -mx-1 px-1 select-none">
|
||||
{STATUS_TABS.map((tab) => {
|
||||
const isActive = (!normalizedStatus && !tab.key) || normalizedStatus === tab.key;
|
||||
const count = stats && tab.countKey ? stats[tab.countKey] : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
type="button"
|
||||
onClick={() => handleStatusChange(tab.key)}
|
||||
className={cn(
|
||||
"group inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition-all cursor-pointer shadow-2xs active:scale-[0.98]",
|
||||
isActive
|
||||
? "bg-accent text-white shadow-sm ring-2 ring-accent/25"
|
||||
: "border border-line bg-panel text-ink/80 hover:bg-slate-50 dark:hover:bg-zinc-800 hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{count != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md px-1.5 py-0.5 text-[10.5px] tabular-nums font-bold leading-none transition-colors",
|
||||
isActive
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-slate-100 dark:bg-zinc-800 text-muted group-hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{fmtNum(count)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* برچسب چیپ تأمینکننده در صورت فعال بودن با امکان حذف سریع */}
|
||||
{filters.supplier && (
|
||||
<span className="inline-flex h-9 shrink-0 items-center gap-1 rounded-xl border border-accent/40 bg-accent-bg px-2.5 text-xs font-semibold text-accent-dark">
|
||||
<span className="max-w-[120px] truncate">تأمینکننده: {filters.supplier}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSupplierFilter}
|
||||
className="rounded-full p-0.5 hover:bg-accent/20 cursor-pointer"
|
||||
title="حذف فیلتر تأمینکننده"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== باتمشیت کشویی فیلترهای تکمیلی (موبایل و تبلت) ===== */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs md:hidden">
|
||||
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs">
|
||||
<div className="fixed inset-0" onClick={() => setIsOpen(false)} aria-hidden="true" />
|
||||
<div className="relative z-10 max-h-[85vh] w-full overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 shadow-2xl">
|
||||
<div className="relative z-10 max-h-[85vh] w-full max-w-lg mx-auto overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 sm:p-5 shadow-2xl">
|
||||
{/* هدر کشو */}
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-bold text-ink">فیلترهای پیشرفته</span>
|
||||
<span className="text-sm font-bold text-ink">فیلترهای تکمیلی</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{activeFilterCount > 0 && (
|
||||
{(filters.supplier || filters.status || filters.search) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
|
||||
onClick={clearAllFilters}
|
||||
className="inline-flex items-center gap-1 text-xs text-rose-600 dark:text-rose-400 font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
پاک کردن
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
پاک کردن همه
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded-lg p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
||||
className="rounded-lg p-1 text-muted hover:bg-slate-50 dark:hover:bg-zinc-800 hover:text-ink cursor-pointer"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -152,21 +238,9 @@ export function FilterToolbar({
|
||||
</div>
|
||||
|
||||
{/* دراپداونها داخل کشو */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
|
||||
<Combobox
|
||||
value={filters.category}
|
||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
||||
items={categories}
|
||||
allowAdd={false}
|
||||
placeholder="همه دستهها"
|
||||
onInputChange={(v) => set({ category: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">تأمینکننده</label>
|
||||
<label className="mb-1.5 block text-xs font-bold text-ink">تأمینکننده</label>
|
||||
<Combobox
|
||||
value={filters.supplier}
|
||||
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||
@@ -178,7 +252,7 @@ export function FilterToolbar({
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||
<label className="mb-1.5 block text-xs font-bold text-ink">وضعیت</label>
|
||||
<Combobox
|
||||
value={statusDisplayValue}
|
||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||
@@ -194,72 +268,13 @@ export function FilterToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="mt-5 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99]"
|
||||
className="mt-6 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99] transition-transform cursor-pointer"
|
||||
>
|
||||
مشاهده نتایج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
|
||||
<div className="hidden md:flex flex-wrap items-end gap-3">
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>دسته</label>
|
||||
<Combobox
|
||||
value={filters.category}
|
||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
||||
items={categories}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ category: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[170px] flex-1">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
value={filters.supplier}
|
||||
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||
items={suppliers}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ supplier: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>وضعیت</label>
|
||||
<Combobox
|
||||
value={statusDisplayValue}
|
||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||
items={STATUS_OPTIONS}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[200px] flex-1">
|
||||
<label>جستجو</label>
|
||||
<input
|
||||
type="search"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="جستجو در نام/برند/پروژه..."
|
||||
value={filters.search}
|
||||
onChange={(e) => set({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[140px]">
|
||||
<label>ترتیب</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm hover:bg-slate-bg"
|
||||
onClick={onToggleSort}
|
||||
title={sortDir === "desc" ? "تغییر به قدیمیترین اول" : "تغییر به جدیدترین اول"}
|
||||
>
|
||||
<span aria-hidden className="text-accent">⇅</span>
|
||||
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
|
||||
onToggleExpand: (id: string) => void;
|
||||
onAccept: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
canWrite?: boolean;
|
||||
}
|
||||
|
||||
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
||||
@@ -20,6 +21,7 @@ export function LedgerTable({
|
||||
onToggleExpand,
|
||||
onAccept,
|
||||
onEdit,
|
||||
canWrite = true,
|
||||
}: LedgerTableProps) {
|
||||
if (!quotes.length) {
|
||||
return (
|
||||
@@ -40,6 +42,7 @@ export function LedgerTable({
|
||||
onToggleExpand={() => onToggleExpand(q.id)}
|
||||
onAccept={() => onAccept(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]">
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
Boxes,
|
||||
Calendar,
|
||||
Hash,
|
||||
CreditCard,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Package,
|
||||
TriangleAlert,
|
||||
AlarmClock,
|
||||
} from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
materialById,
|
||||
materialLabel,
|
||||
formatMaterialTitle,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
termsheetSummary,
|
||||
unitWarning,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { topicByName } from "../../lib/topics";
|
||||
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { QuoteDetail } from "./QuoteDetail";
|
||||
|
||||
@@ -24,270 +34,276 @@ export interface QuoteCardProps {
|
||||
onToggleExpand: () => void;
|
||||
onAccept: () => void;
|
||||
onEdit: () => void;
|
||||
canWrite?: boolean;
|
||||
}
|
||||
|
||||
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
|
||||
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// کارت پیشنهاد خرید — بهینهشده برای کاربری سرپا با موبایل
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||
export function QuoteCard({
|
||||
store,
|
||||
q,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onAccept,
|
||||
onEdit,
|
||||
canWrite = true,
|
||||
}: QuoteCardProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
const total = quoteTotal(q);
|
||||
const pc = priceCell(total);
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
|
||||
// ستون / دکمه پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button
|
||||
onClick={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"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span>پذیرش</span>
|
||||
</button>
|
||||
);
|
||||
} else if (q.accepted_date) {
|
||||
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
||||
} else {
|
||||
acceptedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
// وضعیت رسید کالا
|
||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
||||
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
||||
const fulfilledItems = activeItems.filter(
|
||||
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
||||
).length;
|
||||
const anyReceived = receivedSum > 0;
|
||||
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
||||
const primaryLineItem = activeItems[0];
|
||||
const primaryMaterial = primaryLineItem
|
||||
? materialById(store.materials, primaryLineItem.material_id)
|
||||
: null;
|
||||
|
||||
// نشان پیشرفت تکمیل
|
||||
let fulfillmentBadge: React.ReactNode = null;
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
if (fullyDelivered || q.status === "arrived") {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10.5px] font-bold text-emerald-700 sm:text-[11px]">
|
||||
✓ تکمیل
|
||||
</span>
|
||||
);
|
||||
} else if (anyReceived) {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10.5px] font-bold text-amber-700 sm:text-[11px]">
|
||||
رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10.5px] font-bold text-blue-700 sm:text-[11px]">
|
||||
⏳ در راه
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// عنوان اصلی کالا / متریال
|
||||
let itemTitle = "پیشنهاد بدون قلم";
|
||||
if (activeItems.length === 1 && primaryMaterial) {
|
||||
itemTitle = formatMaterialTitle(primaryMaterial);
|
||||
} else if (activeItems.length > 1 && primaryMaterial) {
|
||||
itemTitle = `${formatMaterialTitle(primaryMaterial)} (+${fmtNum(activeItems.length - 1)} قلم دیگر)`;
|
||||
}
|
||||
|
||||
let arrivedCell: React.ReactNode;
|
||||
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
||||
if (!li.arrived_date) return acc;
|
||||
const t = new Date(li.arrived_date).getTime();
|
||||
if (Number.isNaN(t)) return acc;
|
||||
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
||||
}, null);
|
||||
// آیکون دستهبندی
|
||||
const topicName = primaryMaterial?.topic || sup.topic;
|
||||
const topic = topicByName(topicName, store.topics);
|
||||
const categoryIcon = topic?.icon;
|
||||
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else if (receivedSum > 0) {
|
||||
arrivedCell = (
|
||||
<span className="tabular-nums">
|
||||
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
|
||||
}
|
||||
// محاسبه مجموع مقدار و واحدها
|
||||
let quantityDisplay = "—";
|
||||
if (activeItems.length === 1 && primaryLineItem) {
|
||||
quantityDisplay =
|
||||
primaryLineItem.quantity != null
|
||||
? `${fmtNum(primaryLineItem.quantity)} ${primaryLineItem.unit || ""}`
|
||||
: "—";
|
||||
} else if (activeItems.length > 1) {
|
||||
const totalQty = activeItems.reduce((acc, li) => acc + (li.quantity || 0), 0);
|
||||
const uniqueUnits = [...new Set(activeItems.map((li) => li.unit).filter(Boolean))].join("، ");
|
||||
quantityDisplay = `${fmtNum(totalQty)} ${uniqueUnits || "واحد"} (${fmtNum(activeItems.length)} قلم)`;
|
||||
}
|
||||
|
||||
// نام پروژه
|
||||
const projectName =
|
||||
q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q);
|
||||
|
||||
// بج وضعیت با کدگذاری رنگی معنایی (Semantic Color Coding)
|
||||
let statusBadge: React.ReactNode;
|
||||
if (expired) {
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 dark:bg-amber-950/40 px-2 py-0.5 text-[11px] font-bold text-amber-700 dark:text-amber-400">
|
||||
<AlarmClock className="h-3 w-3 shrink-0" />
|
||||
منقضی شده
|
||||
</span>
|
||||
);
|
||||
} else if (q.status === "arrived") {
|
||||
arrivedCell = (
|
||||
<span className="tabular-nums">
|
||||
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 dark:text-emerald-300">
|
||||
✓ تکمیل شده
|
||||
</span>
|
||||
);
|
||||
} else if (q.status === "accepted") {
|
||||
const fulfilledItems = activeItems.filter(
|
||||
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
||||
).length;
|
||||
const anyReceived = activeItems.some((li) => (li.received_qty || 0) > 0);
|
||||
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40 px-2.5 py-0.5 text-[11px] font-bold text-blue-700 dark:text-blue-300">
|
||||
{anyReceived ? `در حال خرید (${fmtNum(fulfilledItems)}/${fmtNum(activeItems.length)})` : "در حال خرید"}
|
||||
</span>
|
||||
);
|
||||
} else if (q.status === "rejected") {
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-rose-200 dark:border-rose-800 bg-rose-50 dark:bg-rose-950/40 px-2.5 py-0.5 text-[11px] font-bold text-rose-700 dark:text-rose-300">
|
||||
رد شده
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">—</span>;
|
||||
// offered / پیشفرض در انتظار تایید
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 dark:text-amber-300">
|
||||
در انتظار تایید
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
|
||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||
"overflow-hidden rounded-2xl border bg-panel transition-all shadow-2xs",
|
||||
expired ? "border-amber-300/80 bg-amber-50/10" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60 ring-2 ring-accent/15 shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت */}
|
||||
<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">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Link
|
||||
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]"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
||||
q.is_official
|
||||
? "bg-green-bg text-green"
|
||||
: "bg-slate-bg text-muted",
|
||||
)}
|
||||
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
||||
>
|
||||
{q.is_official ? "رسمی" : "غیررسمی"}
|
||||
</span>
|
||||
</div>
|
||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||
{fulfillmentBadge ?? (
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||
{STATUS_LABEL[q.status]}
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
||||
onClick={onEdit}
|
||||
title="ویرایش پیشنهاد"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">ویرایش</span>
|
||||
</button>
|
||||
<button
|
||||
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
</button>
|
||||
</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">
|
||||
{/* ۱. پروژه — تمامعرض در موبایل */}
|
||||
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="truncate">
|
||||
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||
</span>
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
{activeItems.length > 0 ? (
|
||||
activeItems.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<span key={li.id} className="break-words">
|
||||
{materialLabel(m)}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
{/* ===== سربرگ کارت (Header: راست عنوان و آیکون / چپ وضعیت) ===== */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-line/70 bg-slate-50/50 dark:bg-zinc-800/30 p-3.5 sm:p-4">
|
||||
{/* بخش راست (RTL): آیکون دستهبندی + نام کالا + تأمینکننده */}
|
||||
<div className="flex items-start gap-2.5 min-w-0 flex-1">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent-bg text-accent-dark overflow-hidden shadow-2xs border border-accent/20">
|
||||
{categoryIcon ? (
|
||||
<img src={categoryIcon} alt="" className="h-6 w-6 object-contain" loading="lazy" />
|
||||
) : (
|
||||
<span>—</span>
|
||||
<Package className="h-5 w-5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="truncate text-sm sm:text-[15px] font-bold text-ink leading-tight"
|
||||
title={itemTitle}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
</InfoCell>
|
||||
{itemTitle}
|
||||
</h3>
|
||||
|
||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||
{pc ? (
|
||||
<div>
|
||||
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
|
||||
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
||||
</div>
|
||||
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</InfoCell>
|
||||
{/* سابتایتل: نام تأمینکننده، موقعیت و فاکتور رسمی */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-semibold text-muted hover:text-accent truncate no-underline hover:underline transition-colors"
|
||||
>
|
||||
🏢 {sup.name}
|
||||
</Link>
|
||||
|
||||
{/* ۴. شرایط معامله */}
|
||||
<InfoCell label="شرایط معامله" className="col-span-1">
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
||||
{sup.location && <span className="text-muted/60">· {sup.location}</span>}
|
||||
|
||||
{q.is_official && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.2 text-[9.5px] font-bold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||
فاکتور رسمی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</InfoCell>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بخش چپ: بج وضعیت */}
|
||||
<div className="shrink-0 pt-0.5">{statusBadge}</div>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و اکشنها */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<Package className="h-3 w-3 shrink-0" />
|
||||
<span>تاریخ:</span>
|
||||
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
|
||||
</span>
|
||||
{/* ===== بدنه کارت (Body: گرید دو ستونه فشرده متادیتا) ===== */}
|
||||
<div className="p-3.5 sm:p-4 flex flex-col gap-2.5">
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-2.5">
|
||||
{/* ۱. پروژه */}
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Building2 className="h-4 w-4 shrink-0 text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">پروژه</span>
|
||||
<span className="block truncate text-xs font-bold text-ink" title={projectName}>
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<CreditCard className="h-3 w-3 shrink-0" />
|
||||
<span>اعتبار:</span>
|
||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||
{fmtDate(q.expiry_date)}
|
||||
</b>
|
||||
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
|
||||
</span>
|
||||
{/* ۲. مقدار */}
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Boxes className="h-4 w-4 shrink-0 text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">مقدار</span>
|
||||
<span className="block truncate text-xs font-bold text-ink tabular-nums" title={quantityDisplay}>
|
||||
{quantityDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span>پذیرش:</span>
|
||||
{acceptedCell}
|
||||
</span>
|
||||
{/* ۳. تاریخ */}
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Calendar className="h-4 w-4 shrink-0 text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">تاریخ درخواست</span>
|
||||
<span className="block truncate text-xs font-bold text-ink tabular-nums">
|
||||
{fmtDate(q.quote_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<span>رسید:</span>
|
||||
{arrivedCell}
|
||||
</span>
|
||||
{/* ۴. کد پیگیری */}
|
||||
<div className="flex items-start gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">کد پیگیری</span>
|
||||
<span className="block font-mono font-bold text-[11px] sm:text-xs text-ink break-all select-all leading-tight">
|
||||
{q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* سطر قیمت و شرایط پرداخت (در صورت وجود) */}
|
||||
{total > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-accent-bg/40 dark:bg-accent-bg/10 border border-accent/15 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CreditCard className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[11px] text-muted">مبلغ کل:</span>
|
||||
<span className="text-xs sm:text-sm font-extrabold text-ink tabular-nums">
|
||||
{groupCommas(total, true)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ts.title !== "—" && (
|
||||
<span className="text-[11px] font-medium text-muted truncate">
|
||||
شرایط: {ts.title} {ts.sub ? `(${ts.sub})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* جزئیات تکمیلی بازشونده */}
|
||||
{expanded && <QuoteDetail store={store} q={q} />}
|
||||
{/* ===== فوتر کارت (Footer: اکشنهای استاندارد با تاچ تارگت حداقل ۴۰px) ===== */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-line/70 bg-slate-50/40 dark:bg-zinc-800/20 px-3.5 py-2.5 sm:px-4">
|
||||
{/* دکمه اصلی مشاهده / بستن جزئیات */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="min-h-[40px] h-10 flex-1 inline-flex items-center justify-center gap-1.5 rounded-xl border border-line bg-panel hover:bg-slate-50 dark:hover:bg-zinc-800 text-xs font-bold text-ink px-4 transition-all shadow-2xs cursor-pointer select-none active:scale-[0.99]"
|
||||
>
|
||||
<span>{expanded ? "بستن جزئیات" : "مشاهده جزئیات"}</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 transition-transform text-muted duration-200", expanded && "rotate-180 text-accent")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* دکمههای عملیاتی برای کاربران دارای دسترسی نوشتن */}
|
||||
{canWrite && q.status === "offered" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
className="min-h-[40px] h-10 inline-flex items-center justify-center gap-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs font-bold px-3.5 shadow-xs transition-all cursor-pointer active:scale-95 shrink-0"
|
||||
>
|
||||
<span>پذیرش پیشنهاد</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="min-h-[40px] h-10 inline-flex items-center justify-center gap-1 rounded-xl border border-line bg-panel hover:bg-slate-50 dark:hover:bg-zinc-800 text-xs font-semibold text-muted hover:text-ink px-3 transition-colors cursor-pointer shrink-0"
|
||||
title="ویرایش پیشنهاد"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">ویرایش</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== بخش جزئیات بازشونده (Expanded Detail) ===== */}
|
||||
{expanded && (
|
||||
<div className="border-t border-line" onClick={(e) => e.stopPropagation()}>
|
||||
<QuoteDetail store={store} q={q} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import {
|
||||
Package,
|
||||
CreditCard,
|
||||
CalendarDays,
|
||||
Paperclip,
|
||||
Building2,
|
||||
Ban,
|
||||
Hash,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
@@ -7,13 +18,13 @@ import {
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
refConverted,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
@@ -25,6 +36,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const trackingCode = q.id.startsWith("PR-") ? q.id : `PR-${q.id}`;
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(trackingCode);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = trackingCode;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
toast.success("کد پیگیری با موفقیت کپی شد");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("کپی کد پیگیری با خطا مواجه شد");
|
||||
}
|
||||
};
|
||||
|
||||
// پیشنمایش پیوستها: کلیک روی نام فایل، فایل را نشان میدهد (نه دانلود).
|
||||
// بایتها با توکن گرفته میشوند چون <img>/<iframe> نمیتوانند هدر بفرستند.
|
||||
@@ -76,7 +112,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
||||
const total = lineItemTotal(li);
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
|
||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||
@@ -140,12 +175,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{conv && (
|
||||
<div className="fb-conv">
|
||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
||||
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -188,6 +217,39 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
|
||||
return (
|
||||
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
|
||||
{/* بخش برجسته و اختصاصی کد پیگیری */}
|
||||
<div className="mb-3.5 flex flex-wrap items-center justify-between gap-2.5 rounded-xl border border-line bg-panel p-3 sm:px-4 sm:py-3 shadow-2xs">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-bg text-accent shadow-2xs border border-accent/20">
|
||||
<Hash className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10.5px] font-semibold text-muted">کد پیگیری پیشنهاد</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-xs sm:text-sm font-bold text-ink select-all break-all leading-tight">
|
||||
{trackingCode}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دکمه کپی کد پیگیری */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCode}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition-all cursor-pointer shadow-2xs active:scale-95 shrink-0",
|
||||
copied
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800"
|
||||
: "border-line bg-slate-50 dark:bg-zinc-800 text-ink hover:bg-slate-100 dark:hover:bg-zinc-700 hover:border-accent/40",
|
||||
)}
|
||||
title="کپی کد پیگیری در کلیپبورد"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5 text-muted" />}
|
||||
<span>{copied ? "کپی شد" : "کپی کد"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flow-cols">
|
||||
<div className="flow-col flow-items-col">
|
||||
<div className="flow-col-h">
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FilePlus2, Info } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useProjects } from "../../hooks";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژههای تخصیصیافته
|
||||
export function RequesterHome() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const projects = useProjects();
|
||||
|
||||
// پروژههای کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژههای عمومی)
|
||||
const myProjects = useMemo(() => {
|
||||
const list: { id: string; name: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// ۱. پروژههای موجود در آرایه user.projects
|
||||
for (const p of user?.projects ?? []) {
|
||||
if (p.id && !seen.has(p.id)) {
|
||||
seen.add(p.id);
|
||||
list.push({ id: p.id, name: p.name });
|
||||
}
|
||||
}
|
||||
|
||||
// ۲. پروژهی اختصاصی فعال (project_id)
|
||||
if (user?.project_id && !seen.has(user.project_id)) {
|
||||
seen.add(user.project_id);
|
||||
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیصیافته" });
|
||||
}
|
||||
|
||||
// ۳. پروژههای عضویت member_project_ids تطبیقیافته با لیست پروژهها
|
||||
const memberIdSet = new Set(user?.member_project_ids ?? []);
|
||||
for (const p of projects) {
|
||||
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
|
||||
seen.add(p.id);
|
||||
list.push({ id: p.id, name: p.name });
|
||||
}
|
||||
}
|
||||
|
||||
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
|
||||
if (list.length === 0 && user?.project_name) {
|
||||
const match = projects.find((p) => p.name === user.project_name);
|
||||
if (match && !seen.has(match.id)) {
|
||||
list.push({ id: match.id, name: match.name });
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [user, projects]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="افزودن استعلام"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
شما مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{myProjects.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
|
||||
هنوز پروژهای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{myProjects.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/wizard?project=${encodeURIComponent(p.name)}`}
|
||||
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
|
||||
>
|
||||
<div>
|
||||
<b className="text-[13.5px]">🏢 {p.name}</b>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
|
||||
</div>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
|
||||
<FilePlus2 className="h-5 w-5" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/wizard"
|
||||
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
|
||||
>
|
||||
<FilePlus2 className="h-3.5 w-3.5" />
|
||||
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export function ArrivalsPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
const isPM = user?.role === "pm";
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const canAccess = isAdmin || isPM || isSupervisor;
|
||||
|
||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
@@ -33,9 +35,9 @@ export function ArrivalsPage() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
|
||||
const [arr, whs] = await Promise.all([
|
||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM / سرپرست را بررسی میکند
|
||||
apiV26.pm.onTheWay(activeProject),
|
||||
apiV26.warehouses.list(activeProject),
|
||||
]);
|
||||
@@ -46,13 +48,13 @@ export function ArrivalsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isPM, user?.project_id]);
|
||||
}, [isPM, isSupervisor, user?.project_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin && !isPM) return;
|
||||
if (!canAccess) return;
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [isAdmin, isPM, load]);
|
||||
}, [canAccess, load]);
|
||||
|
||||
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
||||
const orders = useMemo(() => {
|
||||
@@ -212,12 +214,12 @@ export function ArrivalsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin && !isPM) {
|
||||
if (!canAccess) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
|
||||
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,8 +76,9 @@ export function ChangeInventoryPage() {
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
const isPM = user?.role === "pm";
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const canModify = isSupervisor || isAdmin;
|
||||
|
||||
const warehouses = data.warehouses;
|
||||
const whOpts = useMemo(
|
||||
@@ -220,7 +221,7 @@ export function ChangeInventoryPage() {
|
||||
let m = matByName(matName);
|
||||
if (!m) {
|
||||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||||
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
|
||||
m = { id: created.id, name: created.name, canonical_unit: null };
|
||||
}
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
@@ -401,7 +402,7 @@ export function ChangeInventoryPage() {
|
||||
|
||||
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
||||
|
||||
if (isAdmin) {
|
||||
if (!canModify) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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="text-3xl">🔒</div>
|
||||
<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>
|
||||
</PageLayout>
|
||||
</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 isTransfer = type === "TRANSFER";
|
||||
|
||||
@@ -41,10 +41,12 @@ export function DailyControlPage() {
|
||||
|
||||
const txByDay = useMemo(() => {
|
||||
const map = new Map<string, InventoryTransaction[]>();
|
||||
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
|
||||
const projWhIds = projFilter
|
||||
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
|
||||
: null;
|
||||
data.transactions.forEach((t) => {
|
||||
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
|
||||
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
|
||||
const k = gregKey(new Date(t.transaction_date));
|
||||
const arr = map.get(k) || [];
|
||||
@@ -53,7 +55,7 @@ export function DailyControlPage() {
|
||||
});
|
||||
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
||||
return map;
|
||||
}, [data.transactions, data.warehouses, projFilter]);
|
||||
}, [data.transactions, data.warehouses, projFilter, isAdmin]);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
@@ -172,11 +174,15 @@ export function DailyControlPage() {
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-semibold text-slate-800">پروژه فعال</span>
|
||||
<span className="font-semibold text-slate-800">
|
||||
{user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="text-[11.5px] text-slate-400">
|
||||
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11.5px] text-slate-400">
|
||||
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
import { InventoryTopNav } from "./InventoryTopNav";
|
||||
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
||||
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
|
||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
@@ -51,12 +51,14 @@ export function InventoryPage() {
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
const isPM = user?.role === "pm";
|
||||
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const isAdmin = user?.role === "admin";
|
||||
// پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
|
||||
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
||||
category: "",
|
||||
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||
warehouse: params.get("warehouse") || "",
|
||||
material: "",
|
||||
project: "",
|
||||
@@ -68,10 +70,10 @@ export function InventoryPage() {
|
||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||
|
||||
const visibleWarehouses = useMemo(() => {
|
||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||
if (isAdmin) return warehouses;
|
||||
if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||
if (isManager) return warehouses;
|
||||
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]);
|
||||
|
||||
@@ -81,7 +83,6 @@ export function InventoryPage() {
|
||||
inventory.forEach((r) => {
|
||||
const whId = r.warehouse?.id;
|
||||
if (!whId || !visibleIds.has(whId)) return;
|
||||
if (filters.category && r.material.category?.id !== filters.category) return;
|
||||
if (filters.warehouse && whId !== filters.warehouse) return;
|
||||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||
@@ -132,15 +133,15 @@ export function InventoryPage() {
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
|
||||
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project));
|
||||
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (!user || (!isAdmin && !isPM)) {
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||
<Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
|
||||
<EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
@@ -152,10 +153,10 @@ export function InventoryPage() {
|
||||
title="سامانه مدیریت انبار"
|
||||
sub={inventorySubheader(user)}
|
||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
||||
menu={<AppSwitcherMenu activeModule="warehouse" />}
|
||||
actions={
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||
<InventoryTopNav pathname={location.pathname} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -248,42 +249,8 @@ export function InventoryPage() {
|
||||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||
{showMobileFilters && (
|
||||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||
{/* دستهبندیها */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
!filters.category
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
همه دستهبندیها
|
||||
</button>
|
||||
{lookup.categories.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.category === c.id
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* پروژهها (مخصوص ادمین) */}
|
||||
{isAdmin && lookup.projects.length > 0 && (
|
||||
{/* پروژهها (مدیران و ادمین) */}
|
||||
{isManager && lookup.projects.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -324,7 +291,7 @@ export function InventoryPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
پاک کردن فیلترها
|
||||
@@ -347,7 +314,6 @@ export function InventoryPage() {
|
||||
) : (
|
||||
groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<InventoryMobileCard
|
||||
key={g.key}
|
||||
@@ -355,7 +321,6 @@ export function InventoryPage() {
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
@@ -410,7 +375,6 @@ export function InventoryPage() {
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<GroupRow
|
||||
key={g.key}
|
||||
@@ -418,7 +382,6 @@ export function InventoryPage() {
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
@@ -455,7 +418,7 @@ export function InventoryPage() {
|
||||
</PageLayout>
|
||||
|
||||
{/* دکمه شناور تغییر موجودی */}
|
||||
{isPM && (
|
||||
{canModify && (
|
||||
<Link
|
||||
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"
|
||||
@@ -475,7 +438,6 @@ function InventoryMobileCard({
|
||||
isExp,
|
||||
isAdmin,
|
||||
matName,
|
||||
matCatName,
|
||||
wh,
|
||||
projName,
|
||||
unit,
|
||||
@@ -486,7 +448,6 @@ function InventoryMobileCard({
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
@@ -508,11 +469,6 @@ function InventoryMobileCard({
|
||||
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||
{matName}
|
||||
</h3>
|
||||
{matCatName !== "—" && (
|
||||
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
||||
{matCatName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -574,12 +530,11 @@ function InventoryMobileCard({
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||
function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
@@ -591,7 +546,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
|
||||
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
|
||||
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
|
||||
<td className="py-2.5 pe-3">
|
||||
<div>{wh}</div>
|
||||
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاینسیستم دکمهها
|
||||
export function InventoryTopNav({
|
||||
pathname,
|
||||
variant = "pm",
|
||||
}: {
|
||||
pathname: string;
|
||||
variant?: "pm" | "admin";
|
||||
}) {
|
||||
const items =
|
||||
variant === "admin"
|
||||
? [
|
||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
||||
{ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") },
|
||||
]
|
||||
: [
|
||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
||||
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
|
||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
||||
];
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const canViewTabs = isManager || isSupervisor;
|
||||
|
||||
const items: { label: string; to: string; active: boolean }[] = [];
|
||||
// برای تغییر موجودی از همان دکمه شناور دایرهای آبی (+) استفاده میشود و دکمه مستطیلی در بالای صفحه حذف شد
|
||||
if (canViewTabs) {
|
||||
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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,52 +9,42 @@ import { useInventoryData, projName } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
|
||||
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد)
|
||||
// انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
|
||||
export function WarehousesPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
const isPM = user?.role === "pm";
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDesc, setNewDesc] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
|
||||
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازماندهی»
|
||||
if (isAdmin) {
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||
<PageLayout>
|
||||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</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 name = newName.trim();
|
||||
if (!name) return toast.error("نام انبار الزامی است");
|
||||
const projectId = user?.project_id;
|
||||
const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id);
|
||||
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
||||
try {
|
||||
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
||||
@@ -99,13 +89,26 @@ export function WarehousesPage() {
|
||||
backLabel="بازگشت به داشبورد"
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{isPM && (
|
||||
{canManage && (
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<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">
|
||||
{!isSupervisor && 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
|
||||
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام انبار (مثلاً انبار موقت)"
|
||||
placeholder="نام انبار (مثلاً انبار مرکزی)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
@@ -148,7 +151,7 @@ export function WarehousesPage() {
|
||||
>
|
||||
📊 مشاهده داشبورد انبار
|
||||
</button>
|
||||
{isPM && (
|
||||
{canManage && (
|
||||
<>
|
||||
<button
|
||||
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
||||
@@ -162,7 +165,7 @@ export function WarehousesPage() {
|
||||
</>
|
||||
)}
|
||||
</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()}>
|
||||
<input
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
|
||||
@@ -10,20 +10,22 @@ export function useLogout() {
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
pm: "مدیر پروژه",
|
||||
procurement_manager: "مدیر تدارکات",
|
||||
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 "در حال بارگذاری…";
|
||||
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 proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژهها";
|
||||
return `${name} | ${role} – ${proj}`;
|
||||
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
|
||||
return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
|
||||
}
|
||||
|
||||
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخشها بالای صفحه هستند و
|
||||
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
|
||||
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمهی خروجِ تکی هم حذف شده).
|
||||
// منوی همبرگری سرپرست پروژه در پنل انبار
|
||||
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
||||
const items: MenuItem[] = [];
|
||||
if (onLogout) {
|
||||
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
||||
//
|
||||
// دسترسیها (v2.6):
|
||||
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازماندهی —
|
||||
// بدون «تغییر موجودی» و بدون «انبارها».
|
||||
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
|
||||
// بدون «سازماندهی».
|
||||
// آیتمهای بخشهای داخلی انبار
|
||||
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
||||
const isAdmin = role === "admin" || role === "procurement_manager";
|
||||
const isPM = role === "pm";
|
||||
const isAdmin = role === "admin";
|
||||
const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
|
||||
const isSupervisor = role === "supervisor" || isAdmin;
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ 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/daily", active: pathname.startsWith("/inventory/daily") });
|
||||
if (isPM) {
|
||||
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران و سرپرست پروژه
|
||||
if (isManager || isSupervisor) {
|
||||
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") });
|
||||
if (isAdmin) {
|
||||
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
||||
}
|
||||
if (isAdmin) {
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "بازگشت به دفتر", to: "/" });
|
||||
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||
}
|
||||
if (onLogout) {
|
||||
items.push({ divider: true });
|
||||
|
||||
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
|
||||
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
|
||||
|
||||
export interface LookupData {
|
||||
categories: { id: string; slug: string; name: string }[];
|
||||
units: { id: number; code: string; name_fa: string | null }[];
|
||||
brands: { id: string; name: string }[];
|
||||
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||
materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
||||
}
|
||||
|
||||
@@ -27,21 +26,20 @@ export interface InventoryData {
|
||||
|
||||
export function useInventoryData(): InventoryData {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isPM = user?.role === "pm";
|
||||
const projectId = isPM ? user.project_id || undefined : undefined;
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
||||
const [lookup, setLookup] = useState<LookupData>({
|
||||
categories: [], units: [], brands: [], materials: [], projects: [],
|
||||
units: [], brands: [], materials: [], projects: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||
apiV26.lookup.categories(),
|
||||
const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||
apiV26.lookup.units(),
|
||||
apiV26.lookup.brands(),
|
||||
apiV26.lookup.materials(),
|
||||
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
|
||||
apiV26.inventory.list({ project_id: projectId }),
|
||||
apiV26.inventory.transactions({ project_id: projectId }),
|
||||
]);
|
||||
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
|
||||
setLookup({ units, brands, materials: mats, projects: projs });
|
||||
setWarehouses(whs);
|
||||
setInventory(inv);
|
||||
setTransactions(txs);
|
||||
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
|
||||
return m ? m.name : "—";
|
||||
}
|
||||
|
||||
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
|
||||
const c = categories.find((x) => x.id === id);
|
||||
return c ? c.name : "—";
|
||||
}
|
||||
|
||||
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
||||
const p = projects.find((x) => x.id === id);
|
||||
return p ? p.name : "—";
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { api, apiV26, type UserRow } from "../../lib/api";
|
||||
import type { Project } from "../../lib/types";
|
||||
import { refreshProcurementStore, useProjects } from "../../hooks";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||
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 storeProjects = useProjects();
|
||||
const [directProjects, setDirectProjects] = useState<Project[]>([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
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 = canManage;
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoadingProjects(true);
|
||||
const list = await api.projects.list();
|
||||
if (list && Array.isArray(list)) {
|
||||
setDirectProjects(list);
|
||||
}
|
||||
} catch {
|
||||
// fallback silently
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const list = await apiV26.users.list();
|
||||
setUsers(list);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManage) {
|
||||
void loadUsers();
|
||||
void fetchProjects();
|
||||
}
|
||||
}, [canManage, loadUsers, fetchProjects]);
|
||||
|
||||
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
|
||||
|
||||
const syncUsers = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const { users: synced, created } = await apiV26.users.sync();
|
||||
setUsers(synced);
|
||||
toast.success(
|
||||
created > 0
|
||||
? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
|
||||
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||||
);
|
||||
refreshProcurementStore();
|
||||
void fetchProjects();
|
||||
} 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 setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
|
||||
setBusyId(u.id);
|
||||
const next = { ...(u.panels ?? {}) };
|
||||
if (level) next[panel] = level;
|
||||
else delete next[panel];
|
||||
try {
|
||||
await apiV26.users.setPanels(u.id, next);
|
||||
toast.success(`دسترسی «${u.first_name} ${u.last_name}» بهروزرسانی شد ✓`);
|
||||
await loadUsers();
|
||||
refreshProcurementStore();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت دسترسی پنل");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setMemberProjects = async (u: UserRow, nextIds: string[]) => {
|
||||
setBusyId(u.id);
|
||||
try {
|
||||
await apiV26.users.setPanelProjects(u.id, nextIds);
|
||||
toast.success(`عضویت پروژهای «${u.first_name} ${u.last_name}» بهروزرسانی شد ✓`);
|
||||
await loadUsers();
|
||||
refreshProcurementStore();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت عضویت پروژهها");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const panelLabel = (level: string | undefined) =>
|
||||
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
|
||||
|
||||
// سرپرستان و مسئولان تخصیصیافته به یک پروژه
|
||||
const supervisorsOf = (projectId: string) =>
|
||||
users.filter(
|
||||
(u) =>
|
||||
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
|
||||
(u.projects?.some((p) => p.id === projectId) ||
|
||||
u.project_id === projectId ||
|
||||
u.member_project_ids?.includes(projectId)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="سازماندهی"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="organization" />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-5">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
|
||||
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگامسازی» اضافه میشوند. نقشهای سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین میگردد.
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقشها و سطوح دسترسی پنلها ---------- */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold">👥 کاربران، نقشها و سطوح دسترسی پنلها</h3>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||
تعیین نقش سازمانی، تخصیص پنلها (تدارکات، انبار، PM) و مشخص نمودن پروژههای عضویت کاربر
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void syncUsers()}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||
) : !users.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center 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.5 pe-3">نام و مشخصات</th>
|
||||
<th className="py-2.5 pe-3">نقش سازمانی</th>
|
||||
<th className="py-2.5 pe-3">پنل تدارکات</th>
|
||||
<th className="py-2.5 pe-3">پنل انبار</th>
|
||||
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
|
||||
<th className="py-2.5">پروژههای عضویت (محدوده سطح محدود)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
const isAdmin = u.role === "admin";
|
||||
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
|
||||
{/* ۱. نام و نام کاربری */}
|
||||
<td className="py-2.5 pe-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-foreground">
|
||||
{u.first_name} {u.last_name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted">
|
||||
({u.username || "—"})
|
||||
</span>
|
||||
</div>
|
||||
{busyId === u.id && (
|
||||
<span className="text-[10px] text-accent animate-pulse font-medium">
|
||||
در حال ذخیره تغییرات…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* ۲. نقش سازمانی */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.role ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||
>
|
||||
<option value="">— بدون نقش —</option>
|
||||
<option value="admin">ادمین</option>
|
||||
<option value="procurement_manager">مدیر تدارکات</option>
|
||||
<option value="pm">مدیر پروژه</option>
|
||||
<option value="supervisor">سرپرست پروژه</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
|
||||
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۳. پنل تدارکات */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||
) : canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.panels?.procurement ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
|
||||
>
|
||||
<option value="">— بدون تخصیص —</option>
|
||||
<option value="full">کامل</option>
|
||||
<option value="limited">محدود (فقط استعلام)</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۴. پنل انبار */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||
) : canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.panels?.inventory ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
|
||||
>
|
||||
<option value="">— بدون تخصیص —</option>
|
||||
<option value="full">کامل (بدون تغییر موجودی)</option>
|
||||
<option value="limited">محدود (با تغییر موجودی)</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۵. پنل مدیریت پروژه (PM) */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||
) : canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.panels?.pm ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
|
||||
>
|
||||
<option value="">— بدون تخصیص —</option>
|
||||
<option value="full">کامل</option>
|
||||
<option value="limited">محدود</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۶. پروژههای عضویت */}
|
||||
<td className="py-2.5">
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژهها</span>
|
||||
) : hasPanel ? (
|
||||
<div className="w-72">
|
||||
<ProjectMultiSelect
|
||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
values={u.member_project_ids ?? []}
|
||||
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
|
||||
disabled={busyId === u.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||
<p className="text-[11.5px] text-muted">
|
||||
مشاهده وضعیت پروژهها و سرپرستان یا اعضای تخصیصیافته به هر پروژه
|
||||
</p>
|
||||
</div>
|
||||
{loadingProjects && (
|
||||
<span className="text-[11px] text-muted animate-pulse">در حال بهروزرسانی پروژهها…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingProjects && !projects.length ? (
|
||||
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
|
||||
در حال دریافت اطلاعات پروژهها…
|
||||
</div>
|
||||
) : !projects.length ? (
|
||||
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
|
||||
<span>پروژهای برای نمایش یافت نشد یا در دسترس نیست.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchProjects()}
|
||||
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
🔄 تلاش مجدد برای دریافت پروژهها
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => {
|
||||
const sups = supervisorsOf(p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
|
||||
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
|
||||
{p.status || "پروژه فعال"}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-line/50">
|
||||
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
|
||||
{!sups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
|
||||
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{sups.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
|
||||
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
|
||||
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنلها ---------- */}
|
||||
<div className="rounded-xl border border-line bg-panel p-5">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-[15px] font-bold">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||
📖
|
||||
</span>
|
||||
توضیحات پنلها و راهنمای سطوح دسترسی
|
||||
</h3>
|
||||
<p className="mt-1 text-[12px] text-muted">
|
||||
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنلهای سامانه
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
مدل دسترسی ماژولار مبتنی بر پنل
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{/* کارت پنل تدارکات */}
|
||||
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
|
||||
🛒
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
|
||||
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
|
||||
خرید و بازرگانی
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>دسترسی کامل مدیریتی به تمامی بخشهای تدارکات</li>
|
||||
<li>ثبت و ویرایش پیشفاکتورها، شرایط پرداخت و فاکتورها</li>
|
||||
<li>مدیریت تأمینکنندگان، اقلام و تصمیمگیری خرید در همه پروژهها</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>فقط امکان ثبت درخواست استعلام قیمت و پیشفاکتور اولیه</li>
|
||||
<li>محدود منحصراً به پروژههایی که کاربر در آنها عضویت دارد</li>
|
||||
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمتها یا پروژههای غیرعضو</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کارت پنل انبار */}
|
||||
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
|
||||
📦
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
|
||||
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
||||
انبارداری و موجودی
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژهها</li>
|
||||
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارشگیری کلان</li>
|
||||
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>دسترسی فقط به انبارهای پروژههای عضویت کاربر</li>
|
||||
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
|
||||
<li>تفکیک دقیق موجودی و تراکنشهای انبار بر اساس پروژه</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کارت پنل مدیریت پروژه (PM) */}
|
||||
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
|
||||
📊
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
|
||||
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
|
||||
کنترل پروژه و اجرا
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>مدیریت جامع ساختار شکست کار (WBS)، زمانبندی و گانت چارت تعاملی</li>
|
||||
<li>تعریف و ویرایش فعالیتها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
|
||||
<li>پایش شاخصهای پیشرفت زمانی و مالی (EVM) و گزارشهای تحلیلی</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>مشاهده فعالیتها و تسکهای مربوط به پروژههای عضویت کاربر</li>
|
||||
<li>بهروزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسکها</li>
|
||||
<li>تکمیل چکلیستهای کنترل کیفیت (QC) و ثبت گزارشهای اجرایی روزانه</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||
<span className="text-[16px] leading-none">👑</span>
|
||||
<div>
|
||||
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
|
||||
<p className="mt-0.5 text-muted leading-relaxed">
|
||||
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنلها و پروژهها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنلها ندارد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||
<span className="text-[16px] leading-none">🎯</span>
|
||||
<div>
|
||||
<span className="font-bold text-foreground">کارکرد «پروژههای عضویت»:</span>
|
||||
<p className="mt-0.5 text-muted leading-relaxed">
|
||||
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه دادهها و اختیارات وی دقیقاً به پروژههای انتخابی در ستون «پروژههای عضویت» محدود خواهد شد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 { nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddProject,
|
||||
useDeleteProject,
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
@@ -19,13 +19,13 @@ import {
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialLabel,
|
||||
refConverted,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||
return quotes.filter(
|
||||
@@ -40,23 +40,14 @@ function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||
);
|
||||
}
|
||||
|
||||
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
||||
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
||||
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
||||
// برای این پروژه همچنان «پیشنهاد شده» است.
|
||||
function acceptedForProject(q: Quote, name: string): boolean {
|
||||
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||
}
|
||||
|
||||
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
||||
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
||||
function hasTargets(q: Quote): boolean {
|
||||
return (q.line_items || []).some((li) => !!li.target_project);
|
||||
}
|
||||
|
||||
// اقلام مربوط به این پروژه در یک quote:
|
||||
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
||||
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||
@@ -74,6 +65,97 @@ function daysSince(iso: string | null): number | null {
|
||||
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() {
|
||||
useStoreData();
|
||||
@@ -81,56 +163,55 @@ export function ProjectsPage() {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const { standardConversions } = useUnits();
|
||||
const addProject = useAddProject();
|
||||
const deleteProject = useDeleteProject();
|
||||
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, categoryUnits, unitConversions, standardConversions, topics };
|
||||
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
||||
|
||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
const addProj = () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const dup = projects.some((p) => normalize(p.name) === normalize(name));
|
||||
if (dup) {
|
||||
toast.error("این پروژه قبلاً ثبت شده است.");
|
||||
return;
|
||||
const handleAddProject = async (data: Omit<Project, "id">) => {
|
||||
const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
|
||||
if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
|
||||
try {
|
||||
await addProject.mutateAsync({ id: "", ...data });
|
||||
setShowAddModal(false);
|
||||
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 (
|
||||
<div className="min-h-screen">
|
||||
<Header title="پروژهها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<Header title="پروژهها" sub="مدیریت پروژههای مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="add-panel">
|
||||
<h4>+ افزودن پروژهی جدید</h4>
|
||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
||||
<div className="field">
|
||||
<label>نام پروژه *</label>
|
||||
<input
|
||||
type="text"
|
||||
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 className="add-panel flex items-center justify-between">
|
||||
<h4>پروژههای سامانه</h4>
|
||||
{canManage && (
|
||||
<Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
|
||||
<Plus size={15} />
|
||||
افزودن پروژه
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!projects.length && !quotes.length ? (
|
||||
@@ -142,16 +223,37 @@ export function ProjectsPage() {
|
||||
const open = openProject === p.name;
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`proj-item ${open ? "open" : ""}`}
|
||||
onClick={() => setOpenProject(open ? null : p.name)}
|
||||
>
|
||||
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="proj-count">{faNum(count)} پیشنهاد</span>
|
||||
</button>
|
||||
{/* جزئیات دقیقاً زیر همان پروژه باز میشود */}
|
||||
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 min-w-0 text-right"
|
||||
onClick={() => setOpenProject(open ? null : p.name)}
|
||||
>
|
||||
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
||||
<span className="font-medium truncate">{p.name}</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>
|
||||
{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>}
|
||||
</div>
|
||||
);
|
||||
@@ -159,6 +261,13 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
|
||||
<AddProjectModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={handleAddProject}
|
||||
loading={addProject.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,7 +276,6 @@ export function ProjectsPage() {
|
||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
const quotes = projectQuotes(store.quotes, name);
|
||||
|
||||
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
||||
const itemsTotal = (q: Quote) =>
|
||||
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||
@@ -189,25 +297,14 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
|
||||
const itemLine = (li: Quote["line_items"][number]) => {
|
||||
const total = lineItemTotal(li);
|
||||
const conv = refConverted(store, li);
|
||||
return (
|
||||
<div key={li.id}>
|
||||
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
||||
<b>{faNum(total || 0)} تومان</b>
|
||||
{conv && (
|
||||
<span className="pj-conv">
|
||||
{" "}
|
||||
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// دستهبندی ایزوله per-project:
|
||||
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
||||
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
||||
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
||||
const offered = quotes
|
||||
.filter(
|
||||
(q) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox";
|
||||
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
||||
import { materialLabel } from "../../lib/quoteUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||
export function CategoryPage() {
|
||||
@@ -26,6 +27,8 @@ export function CategoryPage() {
|
||||
const addSupplier = useAddSupplier();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
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]);
|
||||
|
||||
@@ -121,7 +124,7 @@ export function CategoryPage() {
|
||||
key={s.id}
|
||||
s={s}
|
||||
topic={cat}
|
||||
deletable
|
||||
deletable={canManage}
|
||||
onDelete={removeSup}
|
||||
materials={supMats.get(s.id) || []}
|
||||
avgLeadTime={supLeadTimes.get(s.id)}
|
||||
@@ -130,10 +133,11 @@ export function CategoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||
</h4>
|
||||
{canManage && (
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||
</h4>
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div className="field">
|
||||
<label>نام تأمینکننده *</label>
|
||||
@@ -182,6 +186,7 @@ export function CategoryPage() {
|
||||
ذخیره تأمینکننده
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { topicByName } from "../../lib/topics";
|
||||
import { useAppStore } from "../../store";
|
||||
import { api } from "../../lib/api";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -13,6 +14,16 @@ import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
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() {
|
||||
@@ -22,6 +33,8 @@ export function SupplierDetailPage() {
|
||||
const quotes = useQuotes();
|
||||
const materials = useMaterials();
|
||||
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 [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
@@ -29,6 +42,19 @@ export function SupplierDetailPage() {
|
||||
const [linked, setLinked] = useState<string[]>([]);
|
||||
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(() => {
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
@@ -48,20 +74,8 @@ export function SupplierDetailPage() {
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// متریالهای خریداریشده از این تأمینکننده (از پیشنهادها)
|
||||
const purchased = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
quotes.forEach((q) => {
|
||||
if (q.supplier_id !== id) return;
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m || !m.category) return;
|
||||
out.add(m.category);
|
||||
});
|
||||
});
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [quotes, materials, id]);
|
||||
// دستهی این تأمینکننده — دسته فقط برچسبِ خودِ تأمینکننده است (v2.7)
|
||||
const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
|
||||
|
||||
if (!s) {
|
||||
return (
|
||||
@@ -80,7 +94,6 @@ export function SupplierDetailPage() {
|
||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||
|
||||
const saveCategories = (names: string[]) => {
|
||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
@@ -128,29 +141,61 @@ export function SupplierDetailPage() {
|
||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
</div>
|
||||
{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-1.5 text-[13.5px]">
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||||
<span>
|
||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||
</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>
|
||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
|
||||
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
{canManage && (
|
||||
<Link
|
||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
)}
|
||||
{firstCat ? (
|
||||
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
||||
‹ بازگشت به دسته
|
||||
@@ -163,7 +208,7 @@ export function SupplierDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
||||
{/* متریالهای این تأمینکننده */}
|
||||
<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">
|
||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||
@@ -176,19 +221,23 @@ export function SupplierDetailPage() {
|
||||
{linked.map((c) => (
|
||||
<span key={c} className="spec-chip">
|
||||
{c}
|
||||
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{canManage && (
|
||||
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MultiSelectCombobox
|
||||
values={[]}
|
||||
onChange={(cats) => saveCategories(cats)}
|
||||
options={materialOptions}
|
||||
placeholder="افزودن متریال..."
|
||||
/>
|
||||
{canManage && (
|
||||
<MultiSelectCombobox
|
||||
values={[]}
|
||||
onChange={(cats) => saveCategories(cats)}
|
||||
options={materialOptions}
|
||||
placeholder="افزودن متریال..."
|
||||
/>
|
||||
)}
|
||||
{purchased.length > 0 && (
|
||||
<div className="mt-3 border-t border-line pt-2">
|
||||
<div className="mb-1 text-[11px] font-semibold text-muted">متریالهای خریداریشده (از پیشنهادها):</div>
|
||||
@@ -203,7 +252,7 @@ export function SupplierDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
||||
{/* سابقه خرید از این تأمینکننده */}
|
||||
<div className="mt-4 max-w-4xl">
|
||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
|
||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و 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[] {
|
||||
@@ -40,7 +38,11 @@ export function SupplierCard({
|
||||
return (
|
||||
<div className="sup-card">
|
||||
<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}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
@@ -60,17 +62,34 @@ export function SupplierCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sc-meta">
|
||||
<span>
|
||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
||||
</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>
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span>
|
||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{materials.length > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-1.5">
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
@@ -82,6 +101,7 @@ export function SupplierCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sc-actions">
|
||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||
<Plus className="h-3 w-3" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useSuppliers } from "../../hooks";
|
||||
import { useAppStore } from "../../store";
|
||||
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
|
||||
);
|
||||
}
|
||||
|
||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useMaterials } from "../../hooks";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
export interface ItemDraft {
|
||||
tempId: string;
|
||||
@@ -41,43 +43,16 @@ export function itemRowLabel(it: ItemDraft): string {
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
|
||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
||||
// نامِ پایه باقی بماند.
|
||||
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
||||
const fallback = (name || "").trim();
|
||||
if (!fallback) return "";
|
||||
let base = fallback;
|
||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||
const parts = [brand, category, ...specs]
|
||||
.map((p) => (p || "").trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const p of parts) {
|
||||
if (!base.includes(p)) continue;
|
||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||
if (!next) continue;
|
||||
base = next;
|
||||
}
|
||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||
if (parts.some((p) => p === base)) return fallback;
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
|
||||
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
_topic: string,
|
||||
item: ItemDraft,
|
||||
field: "cat" | "brand" | "spec",
|
||||
field: "brand" | "spec",
|
||||
allItems: ItemDraft[] = [],
|
||||
apiBrands: string[] = [],
|
||||
apiSpecs: string[] = [],
|
||||
): string[] {
|
||||
// متریالها سراسری هستند — مستقل از تاپیک
|
||||
let list = materials;
|
||||
const cat = item.cat.trim();
|
||||
|
||||
@@ -92,22 +67,18 @@ export function materialOptions(
|
||||
return [...out];
|
||||
};
|
||||
|
||||
if (field === "cat") {
|
||||
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
|
||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (field === "brand") {
|
||||
// برند سراسری است — مستقل از تاپیک و دسته
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
||||
|
||||
if (cat)
|
||||
list = list.filter(
|
||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||
);
|
||||
|
||||
const base = new Set<string>();
|
||||
apiSpecs.forEach((s) => s && base.add(s));
|
||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||
@@ -116,7 +87,7 @@ export function materialOptions(
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
||||
// مرحله ۲ — مدیریت اقلام سفارش
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
@@ -135,7 +106,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
const allMaterialNames = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => {
|
||||
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
|
||||
const base = materialBaseName(m.name, m.brand, m.specs);
|
||||
if (base) set.add(base);
|
||||
});
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
@@ -193,150 +164,205 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
if (!it.specs.length) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||
}
|
||||
const dup = materials.some(
|
||||
(m) =>
|
||||
normalize(m.topic) === normalize(rec.topic) &&
|
||||
normalize(m.category) === normalize(rec.category) &&
|
||||
normalize(m.brand) === normalize(rec.brand) &&
|
||||
specsKey(m.specs) === specsKey(rec.specs),
|
||||
);
|
||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||
if (dup) return;
|
||||
onRemember(rec);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
<span>برند</span>
|
||||
<span>مشخصه</span>
|
||||
<span>تعداد</span>
|
||||
<span>واحد</span>
|
||||
<span>قیمت واحد</span>
|
||||
<span>قیمت کل</span>
|
||||
<div className="space-y-4">
|
||||
{/* هدر */}
|
||||
<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>
|
||||
</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">
|
||||
{faNum(items.length)} قلم
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-field="line_items" className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<Combobox
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<MultiSelectCombobox
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||
placeholder="مشخصه"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
showWords
|
||||
/>
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
const name = v.trim();
|
||||
if (!name) return;
|
||||
void apiV26.catalog
|
||||
.createUnit(name)
|
||||
.then(() => {
|
||||
setUnitOptions((prev) =>
|
||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
showWords
|
||||
/>
|
||||
{/* لیست کارتهای اقلام */}
|
||||
<div data-field="line_items" className="flex flex-col gap-3">
|
||||
{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>
|
||||
|
||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
||||
<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 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
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="نام متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName="!text-xs !h-9 text-right"
|
||||
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
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName="!text-xs !h-9 text-right"
|
||||
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
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||
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
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="مقدار"
|
||||
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
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
const name = v.trim();
|
||||
if (!name) return;
|
||||
void apiV26.catalog
|
||||
.createUnit(name)
|
||||
.then(() => {
|
||||
setUnitOptions((prev) =>
|
||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
inputClassName="!text-xs !h-9 text-right"
|
||||
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
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت هر واحد"
|
||||
showWords
|
||||
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
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل قلم"
|
||||
showWords
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
||||
{/* دکمه افزودن قلم جدید */}
|
||||
<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}
|
||||
>
|
||||
<span className="text-base font-bold leading-none">+</span>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>افزودن قلم جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface Step4Props {
|
||||
onAssign: (tempId: string, proj: string) => void;
|
||||
onUnassign: (tempId: string, proj: string) => void;
|
||||
onAddProject: (name: string) => void;
|
||||
/** سطح limited تدارکات: افزودن پروژه جدید ممنوع */
|
||||
canAddProject?: boolean;
|
||||
}
|
||||
|
||||
const ALL_KEY = "__all__";
|
||||
@@ -111,7 +113,7 @@ function DroppableProject({
|
||||
}
|
||||
|
||||
// مرحله ۴ — تخصیص پروژهها با درگاندروپ (یا کلیک-قلم بعد کلیک-پروژه)
|
||||
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) {
|
||||
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) {
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [newProj, setNewProj] = useState("");
|
||||
|
||||
@@ -211,10 +213,14 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
||||
/>
|
||||
))}
|
||||
{!projects.length && (
|
||||
<div className="dd-empty">هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
|
||||
<div className="dd-empty">
|
||||
{canAddProject
|
||||
? "هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید."
|
||||
: "هنوز پروژهای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
|
||||
</div>
|
||||
)}
|
||||
{/* افزودن پروژه بهصورت درجا — زیر لیست پروژهها */}
|
||||
<div className="mt-3 rounded-md border border-dashed border-line p-2">
|
||||
{/* افزودن پروژه بهصورت درجا — زیر لیست پروژهها (سطح limited: ممنوع) */}
|
||||
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
|
||||
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژهی جدید:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -9,40 +9,58 @@ export interface StepperProps {
|
||||
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) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
||||
{STEPS.map((label, i) => {
|
||||
const step = i + 1;
|
||||
<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(({ id: step, label }) => {
|
||||
const isCurrent = step === current;
|
||||
const isComplete = completedSteps.has(step);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
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
|
||||
? "bg-[#2563eb] font-bold text-white"
|
||||
: isComplete
|
||||
? "bg-green-bg font-medium text-green"
|
||||
: "hover:bg-slate-bg",
|
||||
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
onClick={() => onGoto(step)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||
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
|
||||
className={cn(
|
||||
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||
!isCurrent && "hidden md:inline",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -20,8 +20,9 @@ import { topicByName } from "../../lib/topics";
|
||||
import { useAppStore } from "../../store";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { matchMaterial } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
@@ -49,6 +51,23 @@ export function WizardPage() {
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
// سطح limited تدارکات: استعلام فقط برای پروژههای عضویت — بدون افزودن پروژه جدید
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLimitedProcurement =
|
||||
user?.panels?.procurement === "limited" && user?.role !== "admin";
|
||||
const memberProjectNames = useMemo(() => {
|
||||
const allowedIds = new Set([
|
||||
...(user?.member_project_ids ?? []),
|
||||
...(user?.projects ?? []).map((p) => p.id),
|
||||
...(user?.project_id ? [user.project_id] : []),
|
||||
]);
|
||||
const matched = projects
|
||||
.filter((p) => allowedIds.has(p.id))
|
||||
.map((p) => p.name);
|
||||
const directNames = (user?.projects ?? []).map((p) => p.name);
|
||||
if (user?.project_name) directNames.push(user.project_name);
|
||||
return [...new Set([...matched, ...directNames])];
|
||||
}, [projects, user]);
|
||||
const topics = useAppStore((s) => s.topics);
|
||||
const createQuote = useCreateQuote();
|
||||
const addSupplier = useAddSupplier();
|
||||
@@ -83,22 +102,37 @@ export function WizardPage() {
|
||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||
// ---------- prefill از لینکها (?topic=&supplier=&project=) ----------
|
||||
useEffect(() => {
|
||||
const t = params.get("topic");
|
||||
if (!t) return;
|
||||
const topic = topicByName(t, topics);
|
||||
if (!topic) return;
|
||||
setSelectedTopic(topic.name);
|
||||
const sid = params.get("supplier");
|
||||
if (sid) {
|
||||
const sup = suppliers.find((s) => s.id === sid);
|
||||
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
||||
setSupplierName(sup.name);
|
||||
setSupplierInvalid(false);
|
||||
if (t) {
|
||||
const topic = topicByName(t, topics);
|
||||
if (topic) {
|
||||
setSelectedTopic(topic.name);
|
||||
const sid = params.get("supplier");
|
||||
if (sid) {
|
||||
const sup = suppliers.find((s) => s.id === sid);
|
||||
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
||||
setSupplierName(sup.name);
|
||||
setSupplierInvalid(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [params, suppliers, topics]);
|
||||
|
||||
const proj = params.get("project");
|
||||
if (proj) {
|
||||
setAssignment((cur) => {
|
||||
const next = { ...cur };
|
||||
for (const it of items) {
|
||||
if (!next[it.tempId]?.length) {
|
||||
next[it.tempId] = [proj];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [params, suppliers, topics, items]);
|
||||
|
||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||
const completedSteps = useMemo(() => {
|
||||
@@ -127,19 +161,38 @@ export function WizardPage() {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (_rec: MaterialRec) => {
|
||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||
};
|
||||
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
// ---------- پروژهها ----------
|
||||
const projectNames = useMemo(() => {
|
||||
if (isLimitedProcurement) {
|
||||
return memberProjectNames;
|
||||
}
|
||||
const names = new Set<string>(projects.map((p) => p.name));
|
||||
quotes.forEach((q) => {
|
||||
if (q.project) names.add(q.project);
|
||||
(q.projects || []).forEach((n) => names.add(n));
|
||||
});
|
||||
return [...names];
|
||||
}, [projects, quotes]);
|
||||
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
|
||||
|
||||
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص مییابند
|
||||
useEffect(() => {
|
||||
if (isLimitedProcurement && memberProjectNames.length === 1) {
|
||||
const sole = memberProjectNames[0];
|
||||
setAssignment((cur) => {
|
||||
let changed = false;
|
||||
const next = { ...cur };
|
||||
for (const it of items) {
|
||||
if (!next[it.tempId]?.length) {
|
||||
next[it.tempId] = [sole];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : cur;
|
||||
});
|
||||
}
|
||||
}, [isLimitedProcurement, memberProjectNames, items]);
|
||||
|
||||
const assign = (tempId: string, proj: string) => {
|
||||
setAssignment((a) => {
|
||||
@@ -159,7 +212,7 @@ export function WizardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- خلاصهی مرحله ۵ ----------
|
||||
// ---------- خلاصه مرحله ۵ ----------
|
||||
const review = useMemo(() => {
|
||||
let grandTotal = 0;
|
||||
items.forEach((it) => {
|
||||
@@ -202,23 +255,14 @@ export function WizardPage() {
|
||||
|
||||
const combined = `${field} ${msg}`.toLowerCase();
|
||||
|
||||
// ۱. تأمینکننده
|
||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||
|
||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||
return { step: 2, field: field || "line_items" };
|
||||
}
|
||||
|
||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||
return { step: 3, field: field || "termsheets" };
|
||||
}
|
||||
|
||||
// ۴. پروژه
|
||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||
|
||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
||||
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 (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||
@@ -293,20 +337,13 @@ export function WizardPage() {
|
||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
looksLikeUuid(m.id) &&
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
}
|
||||
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛
|
||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
||||
if (!material && cat) {
|
||||
material = materials.find(
|
||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||
const matched = matchMaterial(
|
||||
materials.filter((m) => looksLikeUuid(m.id)),
|
||||
cat,
|
||||
brand,
|
||||
specs,
|
||||
);
|
||||
if (matched) material = matched;
|
||||
}
|
||||
let materialId: string;
|
||||
if (material && looksLikeUuid(material.id)) {
|
||||
@@ -317,7 +354,6 @@ export function WizardPage() {
|
||||
id: nextId("m", materials),
|
||||
name: wantName,
|
||||
topic: selectedTopic,
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit || "عدد",
|
||||
@@ -430,18 +466,21 @@ export function WizardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-screen pb-10">
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
backTo={isLimitedProcurement ? "/requester" : "/"}
|
||||
backLabel={isLimitedProcurement ? "بازگشت به استعلامها" : "بازگشت به دفتر"}
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
/>
|
||||
<PageLayout>
|
||||
{/* نوار مراحل استپر */}
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||
{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
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
@@ -465,12 +504,12 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
@@ -481,13 +520,14 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
assignment={assignment}
|
||||
onAssign={assign}
|
||||
onUnassign={unassign}
|
||||
canAddProject={!isLimitedProcurement}
|
||||
onAddProject={(name) => {
|
||||
void addProject.mutateAsync({ id: nextId("p", projects), name });
|
||||
}}
|
||||
@@ -495,7 +535,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
@@ -510,15 +550,14 @@ export function WizardPage() {
|
||||
</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>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
<span>قبلی</span>
|
||||
@@ -526,21 +565,19 @@ export function WizardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
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>
|
||||
|
||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
||||
{currentStep < TOTAL_STEPS && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -551,7 +588,7 @@ export function WizardPage() {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<Check className="h-4 w-4" />
|
||||
@@ -560,21 +597,21 @@ export function WizardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||
{/* دیالوگ فیلدهای خالی */}
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[13.5px] leading-7">
|
||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
||||
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||
{emptyFields.map((f) => (
|
||||
<button
|
||||
key={f.step}
|
||||
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={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(f.step);
|
||||
@@ -601,7 +638,7 @@ export function WizardPage() {
|
||||
goToStep(emptyFields[0]?.step ?? 1);
|
||||
}}
|
||||
>
|
||||
تکمیل فیلد ها
|
||||
تکمیل فیلدها
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,11 +11,15 @@ import { persist } from "zustand/middleware";
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
casdoor_id: string;
|
||||
username?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||
role_label: string | null;
|
||||
// لایه پنلها (جایگزین نقشها)
|
||||
panels?: Record<string, string>;
|
||||
member_project_ids?: string[];
|
||||
project_id: string | null; // پروژهی فعال (PM) — با سوییچر عوض میشود
|
||||
project_name: string | null;
|
||||
projects?: { id: string; name: string }[]; // همهی پروژههای تخصیصیافته (1:N)
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
CategoryUnit,
|
||||
Material,
|
||||
Project,
|
||||
Quote,
|
||||
@@ -15,7 +14,6 @@ import type {
|
||||
Store,
|
||||
Supplier,
|
||||
Topic,
|
||||
UnitConversion,
|
||||
} from "../lib/types";
|
||||
|
||||
interface AppStore {
|
||||
@@ -24,8 +22,6 @@ interface AppStore {
|
||||
materials: Material[];
|
||||
projects: Project[];
|
||||
quotes: Quote[];
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
topics: Topic[]; // دستهبندیهای ۸گانه — از /material-categories
|
||||
|
||||
@@ -45,9 +41,7 @@ interface AppStore {
|
||||
deleteSupplier: (id: string) => void;
|
||||
addMaterial: (m: Material) => void;
|
||||
addProject: (p: Project) => void;
|
||||
setCategoryUnit: (cu: CategoryUnit) => void;
|
||||
setConversion: (c: UnitConversion) => void;
|
||||
deleteConversion: (id: string) => void;
|
||||
deleteProject: (id: string) => void;
|
||||
setStandardConversion: (sc: StandardConversion) => void;
|
||||
deleteStandardConversion: (id: string) => void;
|
||||
|
||||
@@ -62,8 +56,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
||||
materials: [],
|
||||
projects: [],
|
||||
quotes: [],
|
||||
categoryUnits: [],
|
||||
unitConversions: [],
|
||||
standardConversions: [],
|
||||
topics: [],
|
||||
|
||||
@@ -77,8 +69,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
||||
materials: store.materials,
|
||||
projects: store.projects,
|
||||
quotes: store.quotes,
|
||||
categoryUnits: store.categoryUnits,
|
||||
unitConversions: store.unitConversions,
|
||||
standardConversions: store.standardConversions,
|
||||
topics: store.topics,
|
||||
}),
|
||||
@@ -96,24 +86,7 @@ export const useAppStore = create<AppStore>()((set) => ({
|
||||
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
||||
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
||||
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
||||
setCategoryUnit: (cu) =>
|
||||
set((s) => {
|
||||
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
|
||||
const categoryUnits = [...s.categoryUnits];
|
||||
if (idx >= 0) categoryUnits[idx] = cu;
|
||||
else categoryUnits.push(cu);
|
||||
return { categoryUnits };
|
||||
}),
|
||||
setConversion: (c) =>
|
||||
set((s) => {
|
||||
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
|
||||
const unitConversions = [...s.unitConversions];
|
||||
if (idx >= 0) unitConversions[idx] = c;
|
||||
else unitConversions.push(c);
|
||||
return { unitConversions };
|
||||
}),
|
||||
deleteConversion: (id) =>
|
||||
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
|
||||
deleteProject: (id) => set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })),
|
||||
setStandardConversion: (sc) =>
|
||||
set((s) => {
|
||||
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
||||
|
||||
@@ -62,6 +62,15 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* پنهانسازی اسکرولبار برای چیپهای افقی */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* ---------- تایپوگرافی ---------- */
|
||||
.num-words {
|
||||
@apply text-muted;
|
||||
|
||||
@@ -625,4 +625,35 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
||||
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;
|
||||
|
||||
-- ---------- PANEL ACCESS (جایگزین مدل نقش) ----------
|
||||
-- تخصیص پنل + سطح دسترسی به ازای هر کاربر و عضویت M:N پروژهای
|
||||
CREATE TABLE IF NOT EXISTS user_panels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
panel TEXT NOT NULL CHECK (panel IN ('procurement', 'inventory', 'pm')),
|
||||
level TEXT NOT NULL CHECK (level IN ('full', 'limited')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, panel)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_project_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, project_id)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
Reference in New Issue
Block a user