Compare commits
8
Commits
prod-v8
...
387edfb1ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f |
+7
-2
@@ -119,10 +119,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
if not casdoor_id:
|
if not casdoor_id:
|
||||||
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
||||||
|
|
||||||
|
username = str(claims.get("name") or claims.get("preferred_username") or "")
|
||||||
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(
|
user = User(
|
||||||
casdoor_id=casdoor_id,
|
casdoor_id=casdoor_id,
|
||||||
|
username=username or None,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
claims.get("firstName")
|
claims.get("firstName")
|
||||||
or claims.get("firstname")
|
or claims.get("firstname")
|
||||||
@@ -135,9 +137,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
elif username and not user.username:
|
||||||
|
user.username = username
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
project_ids: list = []
|
project_ids: list = []
|
||||||
if user.role == "pm":
|
if user.role in ("supervisor", "pm"):
|
||||||
projects = (
|
projects = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
@@ -159,7 +164,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
|
|
||||||
|
|
||||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||||
"""Every project id managed by the given PM user (oldest first)."""
|
"""Every project id managed by the given PM/supervisor user (oldest first)."""
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project.id)
|
select(Project.id)
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ _V26_DDL = [
|
|||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
|
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.db.base import Base
|
|||||||
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
||||||
|
|
||||||
user_role_enum = Enum(
|
user_role_enum = Enum(
|
||||||
"pm", "procurement_manager", "admin",
|
"pm", "procurement_manager", "admin", "supervisor",
|
||||||
name="user_role", native_enum=True,
|
name="user_role", native_enum=True,
|
||||||
)
|
)
|
||||||
quote_status_enum = Enum(
|
quote_status_enum = Enum(
|
||||||
@@ -58,6 +58,7 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(Text)
|
||||||
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
phone: Mapped[str | None] = mapped_column(Text)
|
phone: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -148,12 +149,23 @@ class Project(Base):
|
|||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
|
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
start_date: Mapped[date | None] = mapped_column(Date)
|
start_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
|
||||||
|
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
|
||||||
# v2.6: the single PM responsible for this project (confirms arrivals).
|
# v2.6: the single PM responsible for this project (confirms arrivals).
|
||||||
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="SET NULL")
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Quote(Base):
|
class Quote(Base):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||||
from app.models.procurement import LineItemSpec
|
from app.models.procurement import LineItemSpec
|
||||||
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
||||||
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_attribute_definition(
|
||||||
|
payload: AttributeDefinitionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
|||||||
|
|
||||||
@router.patch("/{definition_id}")
|
@router.patch("/{definition_id}")
|
||||||
async def update_attribute_definition(
|
async def update_attribute_definition(
|
||||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
definition_id,
|
||||||
|
payload: AttributeDefinitionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{definition_id}")
|
@router.delete("/{definition_id}")
|
||||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
async def delete_attribute_definition(
|
||||||
|
definition_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.config import settings
|
|||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.routers.users import _user_projects, user_to_dict
|
from app.routers.users import _user_projects, user_to_dict
|
||||||
from app.services import analytics
|
from app.services import analytics
|
||||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
|
||||||
from app.utils.ratelimit import limiter
|
from app.utils.ratelimit import limiter
|
||||||
|
|
||||||
logger = logging.getLogger("procurement.auth")
|
logger = logging.getLogger("procurement.auth")
|
||||||
@@ -78,6 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
claims = await _verify_token(access_token)
|
claims = await _verify_token(access_token)
|
||||||
ctx = await context_from_claims(claims, db)
|
ctx = await context_from_claims(claims, db)
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
if ctx.role == "supervisor" and not projects:
|
||||||
|
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||||
await analytics.track(
|
await analytics.track(
|
||||||
"login_success",
|
"login_success",
|
||||||
{"username": payload.username, "role": ctx.role},
|
{"username": payload.username, "role": ctx.role},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material
|
from app.models.catalog import Brand, BrandMaterial, Material
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||||
@@ -70,7 +72,11 @@ async def list_brand_materials(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand_material(
|
||||||
|
payload: BrandMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
if await db.get(Brand, payload.brand_id) is None:
|
if await db.get(Brand, payload.brand_id) is None:
|
||||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||||
if await db.get(Material, payload.material_id) is None:
|
if await db.get(Material, payload.material_id) is None:
|
||||||
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{bm_id}")
|
@router.patch("/{bm_id}")
|
||||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand_material(
|
||||||
|
bm_id,
|
||||||
|
payload: BrandMaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{bm_id}")
|
@router.delete("/{bm_id}")
|
||||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand_material(
|
||||||
|
bm_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial
|
from app.models.catalog import Brand, BrandMaterial
|
||||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def brand_to_dict(brand: Brand) -> dict:
|
def brand_to_dict(brand: Brand) -> dict:
|
||||||
@@ -42,7 +44,11 @@ async def list_brands(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand(
|
||||||
|
payload: BrandCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||||
if any(b.country == payload.country for b in rows):
|
if any(b.country == payload.country for b in rows):
|
||||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{brand_id}")
|
@router.patch("/{brand_id}")
|
||||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand(
|
||||||
|
brand_id,
|
||||||
|
payload: BrandUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{brand_id}")
|
@router.delete("/{brand_id}")
|
||||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand(
|
||||||
|
brand_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
|
|||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||||
# برای هر کاربر واردشده آزاد است.
|
# برای هر کاربر واردشده آزاد است.
|
||||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||||
|
_attachment_writer = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||||
@@ -125,7 +126,7 @@ async def _attachment_or_404(
|
|||||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
|||||||
# Proforma — no status restriction
|
# Proforma — no status restriction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
|
|||||||
@@ -77,23 +77,21 @@ async def inventory_scope(
|
|||||||
if user is not None:
|
if user is not None:
|
||||||
if user.role is None:
|
if user.role is None:
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
# مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||||
if user.role == "procurement_manager":
|
if user.role in ("procurement_manager", "pm"):
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="manager", project_id=project_id, actor=user.display_name)
|
||||||
if user.role not in ("pm", "admin"):
|
if user.role == "supervisor":
|
||||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
|
||||||
if user.role == "pm":
|
|
||||||
allowed = list(user.project_ids or [])
|
allowed = list(user.project_ids or [])
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("پروژه تخصیص داده نشده است")
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="pm", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name)
|
||||||
if not allowed:
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name)
|
||||||
raise ValidationError(
|
if user.role == "admin":
|
||||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
|
||||||
)
|
|
||||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
|
raise ValidationError("نقش نامعتبر است", field="role")
|
||||||
|
|
||||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||||
@@ -128,9 +126,9 @@ async def inventory_scope(
|
|||||||
return Scope(role=role, project_id=project_id, actor=actor)
|
return Scope(role=role, project_id=project_id, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
def _require_pm(scope: Scope) -> None:
|
def _require_inventory_modifier(scope: Scope) -> None:
|
||||||
if scope.role != ROLE_PM:
|
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -215,8 +213,8 @@ async def list_warehouses(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(Warehouse)
|
stmt = select(Warehouse)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
stmt = stmt.order_by(Warehouse.name)
|
stmt = stmt.order_by(Warehouse.name)
|
||||||
@@ -323,7 +321,7 @@ async def list_inventory(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(InventoryItem)
|
stmt = select(InventoryItem)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
Warehouse.project_id == scope.project_id
|
Warehouse.project_id == scope.project_id
|
||||||
)
|
)
|
||||||
@@ -388,7 +386,7 @@ async def list_transactions(
|
|||||||
stmt = select(InventoryTransaction).join(
|
stmt = select(InventoryTransaction).join(
|
||||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
@@ -454,7 +452,7 @@ async def inventory_history(
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise NotFoundError("موجودی یافت نشد")
|
raise NotFoundError("موجودی یافت نشد")
|
||||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id:
|
||||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||||
txs = (await db.execute(
|
txs = (await db.execute(
|
||||||
select(InventoryTransaction)
|
select(InventoryTransaction)
|
||||||
@@ -474,17 +472,17 @@ async def create_transaction(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_require_pm(scope)
|
_require_inventory_modifier(scope)
|
||||||
# No explicit db.begin(): the session may already be inside an implicit
|
# No explicit db.begin(): the session may already be inside an implicit
|
||||||
# transaction (inventory_scope read from it in the JWT path); get_db
|
# transaction (inventory_scope read from it in the JWT path); get_db
|
||||||
# commits on success / rolls back on failure.
|
# commits on success / rolls back on failure.
|
||||||
with nullcontext():
|
with nullcontext():
|
||||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||||
if wh.project_id != scope.project_id:
|
if scope.project_id is not None and wh.project_id != scope.project_id:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if payload.dest_warehouse_id is not None:
|
if payload.dest_warehouse_id is not None:
|
||||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||||
if dest.project_id != scope.project_id:
|
if scope.project_id is not None and dest.project_id != scope.project_id:
|
||||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||||
await audit_record(
|
await audit_record(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||||
tags=["item-receipts"],
|
tags=["item-receipts"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
async def create_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await _decision_or_404(db, line_item_id)
|
decision = await _decision_or_404(db, line_item_id)
|
||||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||||
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("")
|
||||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||||
if decision is None:
|
if decision is None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||||
tags=["item-termsheets"],
|
tags=["item-termsheets"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_item_termsheet(
|
async def create_item_termsheet(
|
||||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ItemTermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
@@ -93,7 +99,12 @@ async def create_item_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{item_termsheet_id}")
|
@router.patch("/{item_termsheet_id}")
|
||||||
async def update_item_termsheet(
|
async def update_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
payload: ItemTermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
|||||||
|
|
||||||
@router.delete("/{item_termsheet_id}")
|
@router.delete("/{item_termsheet_id}")
|
||||||
async def delete_item_termsheet(
|
async def delete_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Unit
|
from app.models.catalog import AttributeDefinition, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||||
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
|
|||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
|
|||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("")
|
||||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
async def put_line_item_specs(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: SpecsPutIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, Unit
|
from app.models.catalog import Material, Unit
|
||||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||||
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
async def create_line_item(
|
||||||
|
quote_id,
|
||||||
|
payload: LineItemCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
|||||||
|
|
||||||
@router.patch("/{line_item_id}")
|
@router.patch("/{line_item_id}")
|
||||||
async def update_line_item(
|
async def update_line_item(
|
||||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: LineItemUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
@@ -131,7 +142,12 @@ async def update_line_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{line_item_id}")
|
@router.delete("/{line_item_id}")
|
||||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
async def delete_line_item(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Supplier
|
from app.models.procurement import Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def category_to_dict(category: MaterialCategory) -> dict:
|
def category_to_dict(category: MaterialCategory) -> dict:
|
||||||
@@ -46,7 +48,11 @@ async def list_categories(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
async def create_category(
|
||||||
|
payload: MaterialCategoryCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||||
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{category_id}")
|
@router.patch("/{category_id}")
|
||||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_category(
|
||||||
|
category_id,
|
||||||
|
payload: MaterialCategoryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{category_id}")
|
@router.delete("/{category_id}")
|
||||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
async def delete_category(
|
||||||
|
category_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import (
|
from app.models.catalog import (
|
||||||
AttributeDefinition,
|
AttributeDefinition,
|
||||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def _coerce_filter_value(data_type: str, raw: str):
|
def _coerce_filter_value(data_type: str, raw: str):
|
||||||
@@ -125,7 +127,10 @@ async def list_materials(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_material(
|
async def create_material(
|
||||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
payload: MaterialCreate,
|
||||||
|
expand: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
material = await material_service.create_material(db, payload)
|
material = await material_service.create_material(db, payload)
|
||||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||||
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{material_id}")
|
@router.patch("/{material_id}")
|
||||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_material(
|
||||||
|
material_id,
|
||||||
|
payload: MaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}")
|
@router.delete("/{material_id}")
|
||||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material(
|
||||||
|
material_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
@@ -248,7 +262,10 @@ async def list_material_units(
|
|||||||
|
|
||||||
@router.post("/{material_id}/units", status_code=201)
|
@router.post("/{material_id}/units", status_code=201)
|
||||||
async def create_material_unit(
|
async def create_material_unit(
|
||||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialUnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
await _material_or_404(db, material_id)
|
await _material_or_404(db, material_id)
|
||||||
@@ -287,7 +304,11 @@ async def create_material_unit(
|
|||||||
|
|
||||||
@router.patch("/{material_id}/units/{mu_id}")
|
@router.patch("/{material_id}/units/{mu_id}")
|
||||||
async def update_material_unit(
|
async def update_material_unit(
|
||||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
payload: MaterialUnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
@@ -319,7 +340,12 @@ async def update_material_unit(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}/units/{mu_id}")
|
@router.delete("/{material_id}/units/{mu_id}")
|
||||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material_unit(
|
||||||
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||||
)
|
)
|
||||||
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
|
|||||||
|
|
||||||
@router.put("/{material_id}/attribute-values")
|
@router.put("/{material_id}/attribute-values")
|
||||||
async def put_material_attribute_values(
|
async def put_material_attribute_values(
|
||||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialAttributeValuesPut,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, Quote, User
|
from app.models.procurement import Project, Quote, User
|
||||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||||
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_date, to_iso
|
from app.utils.serialization import to_date, to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||||
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
d = {
|
d = {
|
||||||
"id": str(project.id),
|
"id": str(project.id),
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"location": project.location or "",
|
||||||
|
"project_manager": project.project_manager or "",
|
||||||
|
"description": project.description or "",
|
||||||
"start_date": to_date(project.start_date),
|
"start_date": to_date(project.start_date),
|
||||||
|
"status": project.status or "ACTIVE",
|
||||||
|
"progress": float(project.progress) if project.progress is not None else 0.0,
|
||||||
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
||||||
"created_at": to_iso(project.created_at),
|
"created_at": to_iso(project.created_at),
|
||||||
}
|
}
|
||||||
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
pm = await db.get(User, project.pm_user_id)
|
pm = await db.get(User, project.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif project.project_manager:
|
||||||
|
d["pm_name"] = project.project_manager
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +62,14 @@ async def list_projects(
|
|||||||
):
|
):
|
||||||
stmt = select(Project)
|
stmt = select(Project)
|
||||||
if q:
|
if q:
|
||||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Project.name.ilike(f"%{q}%"),
|
||||||
|
Project.description.ilike(f"%{q}%"),
|
||||||
|
Project.location.ilike(f"%{q}%"),
|
||||||
|
Project.project_manager.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, Project, sort)
|
stmt = sort_query(stmt, Project, sort)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
pm_map = await _pm_map(db, items)
|
pm_map = await _pm_map(db, items)
|
||||||
@@ -64,24 +78,38 @@ async def list_projects(
|
|||||||
d = {
|
d = {
|
||||||
"id": str(p.id),
|
"id": str(p.id),
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"description": p.description,
|
"location": p.location or "",
|
||||||
|
"project_manager": p.project_manager or "",
|
||||||
|
"description": p.description or "",
|
||||||
"start_date": to_date(p.start_date),
|
"start_date": to_date(p.start_date),
|
||||||
|
"status": p.status or "ACTIVE",
|
||||||
|
"progress": float(p.progress) if p.progress is not None else 0.0,
|
||||||
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
||||||
"created_at": to_iso(p.created_at),
|
"created_at": to_iso(p.created_at),
|
||||||
}
|
}
|
||||||
pm = pm_map.get(p.pm_user_id)
|
pm = pm_map.get(p.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif p.project_manager:
|
||||||
|
d["pm_name"] = p.project_manager
|
||||||
data.append(d)
|
data.append(d)
|
||||||
return {"data": data, "meta": meta}
|
return {"data": data, "meta": meta}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
async def create_project(
|
||||||
|
payload: ProjectCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = Project(
|
project = Project(
|
||||||
name=payload.name,
|
name=payload.name.strip(),
|
||||||
description=payload.description,
|
location=payload.location.strip() if payload.location else "",
|
||||||
|
project_manager=payload.project_manager.strip() if payload.project_manager else "",
|
||||||
|
description=payload.description.strip() if payload.description else "",
|
||||||
start_date=payload.start_date,
|
start_date=payload.start_date,
|
||||||
|
planned_start=payload.start_date,
|
||||||
|
status="ACTIVE",
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -103,37 +131,51 @@ async def update_project(
|
|||||||
request: Request,
|
request: Request,
|
||||||
ctx: UserContext | None = Depends(_optional_ctx),
|
ctx: UserContext | None = Depends(_optional_ctx),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
project.name = payload.name
|
project.name = payload.name.strip()
|
||||||
|
if payload.location is not None:
|
||||||
|
project.location = payload.location.strip()
|
||||||
|
if payload.project_manager is not None:
|
||||||
|
project.project_manager = payload.project_manager.strip()
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
project.description = payload.description
|
project.description = payload.description.strip()
|
||||||
if payload.start_date is not None:
|
if payload.start_date is not None:
|
||||||
project.start_date = payload.start_date
|
project.start_date = payload.start_date
|
||||||
|
project.planned_start = payload.start_date
|
||||||
|
if payload.status is not None:
|
||||||
|
project.status = payload.status
|
||||||
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
||||||
# Admin / procurement manager only (same rule as the Organization panel).
|
# Admin / procurement manager only (same rule as the Organization panel).
|
||||||
if "pm_user_id" in payload.model_fields_set:
|
if "pm_user_id" in payload.model_fields_set:
|
||||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role not in ("admin", "procurement_manager"):
|
if role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||||
if payload.pm_user_id is None:
|
if payload.pm_user_id is None:
|
||||||
project.pm_user_id = None
|
project.pm_user_id = None
|
||||||
else:
|
else:
|
||||||
pm_user = await db.get(User, payload.pm_user_id)
|
pm_user = await db.get(User, payload.pm_user_id)
|
||||||
if pm_user is None:
|
if pm_user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||||
if pm_user.role != "pm":
|
if pm_user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
|
||||||
project.pm_user_id = pm_user.id
|
project.pm_user_id = pm_user.id
|
||||||
|
if not project.project_manager:
|
||||||
|
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": await project_to_dict(db, project)}
|
return {"data": await project_to_dict(db, project)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
async def delete_project(
|
||||||
|
project_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote, QuoteEvent
|
from app.models.procurement import Quote, QuoteEvent
|
||||||
from app.schemas.procurement import NoteEventIn
|
from app.schemas.procurement import NoteEventIn
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -34,7 +36,12 @@ async def list_events(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
async def create_event(
|
||||||
|
quote_id,
|
||||||
|
payload: NoteEventIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
if quote is None:
|
if quote is None:
|
||||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import exists, func, or_, select
|
from sqlalchemy import exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material
|
from app.models.catalog import Material
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
@@ -42,6 +43,9 @@ from app.utils.security import get_actor
|
|||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||||
|
|
||||||
|
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
# --- list filter helpers ---------------------------------------------------
|
# --- list filter helpers ---------------------------------------------------
|
||||||
|
|
||||||
@@ -156,7 +160,12 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def create_quote(
|
||||||
|
payload: QuoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.create_quote(db, payload, actor)
|
quote = await quote_service.create_quote(db, payload, actor)
|
||||||
return {"data": await serialize_quote_with_children(db, quote)}
|
return {"data": await serialize_quote_with_children(db, quote)}
|
||||||
|
|
||||||
@@ -167,7 +176,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
|||||||
|
|
||||||
@router.patch("/{quote_id}")
|
@router.patch("/{quote_id}")
|
||||||
async def update_quote(
|
async def update_quote(
|
||||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await serialize_quote(db, quote, set())}
|
return {"data": await serialize_quote(db, quote, set())}
|
||||||
@@ -175,7 +188,11 @@ async def update_quote(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/children")
|
@router.patch("/{quote_id}/children")
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteChildrenReplaceIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||||
@@ -188,7 +205,11 @@ async def replace_quote_children(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/accept")
|
@router.patch("/{quote_id}/accept")
|
||||||
async def accept_quote(
|
async def accept_quote(
|
||||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: AcceptQuoteIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await _accept_response(db, quote)}
|
return {"data": await _accept_response(db, quote)}
|
||||||
@@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/arrive")
|
@router.patch("/{quote_id}/arrive")
|
||||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def arrive_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/arrive-items")
|
@router.patch("/{quote_id}/arrive-items")
|
||||||
async def arrive_items(
|
async def arrive_items(
|
||||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: ArriveItemsIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||||
return {
|
return {
|
||||||
@@ -259,14 +289,23 @@ async def arrive_items(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/reject")
|
@router.patch("/{quote_id}/reject")
|
||||||
async def reject_quote(
|
async def reject_quote(
|
||||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: RejectQuoteIn | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/expire")
|
@router.patch("/{quote_id}/expire")
|
||||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def expire_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
@@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.delete("/{quote_id}")
|
@router.delete("/{quote_id}")
|
||||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
async def delete_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
result = await quote_service.delete_quote(db, quote_id)
|
result = await quote_service.delete_quote(db, quote_id)
|
||||||
return {"data": result}
|
return {"data": result}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||||
tags=["schedule-steps"],
|
tags=["schedule-steps"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||||
@@ -57,7 +59,11 @@ async def list_schedule_steps(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_schedule_step(
|
async def create_schedule_step(
|
||||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: ScheduleStepCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
step_no = payload.step_no
|
step_no = payload.step_no
|
||||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
|||||||
|
|
||||||
@router.patch("/{step_id}")
|
@router.patch("/{step_id}")
|
||||||
async def update_schedule_step(
|
async def update_schedule_step(
|
||||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
payload: ScheduleStepUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||||
@@ -109,7 +120,13 @@ async def update_schedule_step(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{step_id}")
|
@router.delete("/{step_id}")
|
||||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
async def delete_schedule_step(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
count = await db.scalar(
|
count = await db.scalar(
|
||||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import StandardConversion
|
from app.models.catalog import StandardConversion
|
||||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_standard_conversion(
|
||||||
|
payload: StandardConversionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
select(StandardConversion.id).where(
|
select(StandardConversion.id).where(
|
||||||
StandardConversion.from_unit == payload.from_unit,
|
StandardConversion.from_unit == payload.from_unit,
|
||||||
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{sc_id}")
|
@router.patch("/{sc_id}")
|
||||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
payload: StandardConversionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{sc_id}")
|
@router.delete("/{sc_id}")
|
||||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
async def delete_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import Material, MaterialCategory
|
||||||
from app.models.procurement import Supplier, SupplierMaterial
|
from app.models.procurement import Supplier, SupplierMaterial
|
||||||
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(tags=["supplier-materials"])
|
router = APIRouter(tags=["supplier-materials"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
||||||
@@ -63,7 +65,10 @@ async def list_supplier_materials(
|
|||||||
|
|
||||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
async def create_supplier_material(
|
async def create_supplier_material(
|
||||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
supplier_id,
|
||||||
|
payload: SupplierMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||||
@@ -72,7 +77,12 @@ async def create_supplier_material(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||||
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier_material(
|
||||||
|
supplier_id,
|
||||||
|
material_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
await unlink_supplier_material(db, supplier_id, material_id)
|
await unlink_supplier_material(db, supplier_id, material_id)
|
||||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Quote, Supplier
|
from app.models.procurement import Quote, Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
async def create_supplier(
|
||||||
|
payload: SupplierCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await _category_or_404(db, payload.category_id)
|
category = await _category_or_404(db, payload.category_id)
|
||||||
supplier = Supplier(
|
supplier = Supplier(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{supplier_id}")
|
@router.patch("/{supplier_id}")
|
||||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_supplier(
|
||||||
|
supplier_id,
|
||||||
|
payload: SupplierUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{supplier_id}")
|
@router.delete("/{supplier_id}")
|
||||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier(
|
||||||
|
supplier_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
async def create_termsheet(
|
||||||
|
quote_id,
|
||||||
|
payload: TermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}")
|
@router.patch("/{termsheet_id}")
|
||||||
async def update_termsheet(
|
async def update_termsheet(
|
||||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
if ts.status != "offered":
|
if ts.status != "offered":
|
||||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}/status")
|
@router.patch("/{termsheet_id}/status")
|
||||||
async def update_termsheet_status(
|
async def update_termsheet_status(
|
||||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetStatusUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{termsheet_id}")
|
@router.delete("/{termsheet_id}")
|
||||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
async def delete_termsheet(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec
|
from app.models.procurement import LineItem, LineItemSpec
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/units", tags=["units"])
|
router = APIRouter(prefix="/units", tags=["units"])
|
||||||
|
_procurement_writer = require_role("admin", "procurement_manager")
|
||||||
|
|
||||||
|
|
||||||
def unit_full_to_dict(unit: Unit) -> dict:
|
def unit_full_to_dict(unit: Unit) -> dict:
|
||||||
@@ -45,7 +47,11 @@ async def list_units(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
async def create_unit(
|
||||||
|
payload: UnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{unit_id}")
|
@router.patch("/{unit_id}")
|
||||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
payload: UnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}")
|
@router.delete("/{unit_id}")
|
||||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
async def delete_unit(
|
||||||
|
unit_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
from app.auth import UserContext, get_current_user, require_role
|
||||||
@@ -41,6 +41,8 @@ USER_ROLE_LABELS = {
|
|||||||
"pm": "مدیر پروژه",
|
"pm": "مدیر پروژه",
|
||||||
"procurement_manager": "مدیر تدارکات",
|
"procurement_manager": "مدیر تدارکات",
|
||||||
"admin": "ادمین",
|
"admin": "ادمین",
|
||||||
|
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||||
|
"supervisor": "سرپرست پروژه",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
|||||||
return {
|
return {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"casdoor_id": user.casdoor_id,
|
"casdoor_id": user.casdoor_id,
|
||||||
|
"username": user.username,
|
||||||
"first_name": user.first_name,
|
"first_name": user.first_name,
|
||||||
"last_name": user.last_name,
|
"last_name": user.last_name,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
@@ -65,15 +68,15 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
"""Admin OR procurement manager (the same person) — organization management."""
|
"""Admin, procurement manager, or project manager — organization management."""
|
||||||
if ctx.role not in ("admin", "procurement_manager"):
|
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
"""Every project assigned to the user (supervisor or PM) (oldest first)."""
|
||||||
if user.role != "pm":
|
if user.role not in ("supervisor", "pm"):
|
||||||
return []
|
return []
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -148,13 +151,13 @@ async def list_users(
|
|||||||
):
|
):
|
||||||
"""Every user (with role + assigned projects) — Organization panel."""
|
"""Every user (with role + assigned projects) — Organization panel."""
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -204,7 +207,7 @@ async def update_user_role(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
previous_role = user.role
|
previous_role = user.role
|
||||||
if user.role == "pm" and payload.role != "pm":
|
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
user.role = payload.role
|
user.role = payload.role
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -231,8 +234,15 @@ async def sync_casdoor_users(
|
|||||||
continue
|
continue
|
||||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
by_uname = await db.scalar(select(User).where(User.username == uname))
|
||||||
|
if by_uname is not None:
|
||||||
|
by_uname.casdoor_id = cid
|
||||||
|
continue
|
||||||
db.add(User(
|
db.add(User(
|
||||||
casdoor_id=cid,
|
casdoor_id=cid,
|
||||||
|
username=uname,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
cu.get("firstName")
|
cu.get("firstName")
|
||||||
or cu.get("displayName")
|
or cu.get("displayName")
|
||||||
@@ -243,16 +253,20 @@ async def sync_casdoor_users(
|
|||||||
phone=str(cu.get("phone") or "") or None,
|
phone=str(cu.get("phone") or "") or None,
|
||||||
))
|
))
|
||||||
created += 1
|
created += 1
|
||||||
|
elif not existing.username:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
existing.username = uname
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -282,11 +296,16 @@ async def assign_user_project(
|
|||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
if user.role != "pm":
|
if user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||||
|
|
||||||
if payload.project_id is None:
|
if payload.project_id is None:
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
|
||||||
|
{"uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": user_to_dict(user)}
|
||||||
|
|
||||||
@@ -294,5 +313,19 @@ async def assign_user_project(
|
|||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
project.pm_user_id = user.id
|
project.pm_user_id = user.id
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO project_assignments (id, user_id, project_id)
|
||||||
|
SELECT gen_random_uuid(), pu.id, :pid
|
||||||
|
FROM pm_users pu
|
||||||
|
WHERE pu.username = :uname
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project_assignments pa
|
||||||
|
WHERE pa.user_id = pu.id AND pa.project_id = :pid
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"pid": project.id, "uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
||||||
|
|||||||
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
|
|||||||
|
|
||||||
class ProjectCreate(APIModel):
|
class ProjectCreate(APIModel):
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(APIModel):
|
class ProjectUpdate(APIModel):
|
||||||
name: str | None = Field(default=None, min_length=1)
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
status: str | None = None
|
||||||
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
||||||
# Admin / procurement manager only; None unassigns this project's PM.
|
# Admin / procurement manager only; None unassigns this project's PM.
|
||||||
pm_user_id: UUID | None = None
|
pm_user_id: UUID | None = None
|
||||||
@@ -203,13 +208,13 @@ class UserCreate(APIModel):
|
|||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
casdoor_id: str | None = None
|
casdoor_id: str | None = None
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserRoleUpdate(APIModel):
|
class UserRoleUpdate(APIModel):
|
||||||
"""role=None revokes the role (and any project assignment)."""
|
"""role=None revokes the role (and any project assignment)."""
|
||||||
|
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserProjectUpdate(APIModel):
|
class UserProjectUpdate(APIModel):
|
||||||
|
|||||||
@@ -248,6 +248,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/srv/backend/app
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- traefik_network
|
- traefik_network
|
||||||
- default
|
- default
|
||||||
|
|||||||
+112
-6
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { useAuthStore } from "./store/auth";
|
import { useAuthStore } from "./store/auth";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { RequirePmProjects } from "./components/RequirePmProjects";
|
import { RequirePmProjects } from "./components/RequirePmProjects";
|
||||||
@@ -38,7 +38,7 @@ const WarehousesPage = lazy(() =>
|
|||||||
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
||||||
);
|
);
|
||||||
const OrganizationPage = lazy(() =>
|
const OrganizationPage = lazy(() =>
|
||||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||||
);
|
);
|
||||||
const AdminPanelPage = lazy(() =>
|
const AdminPanelPage = lazy(() =>
|
||||||
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
||||||
@@ -55,17 +55,102 @@ function Loading() {
|
|||||||
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
|
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const { pathname } = useLocation();
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (user.role === "supervisor") {
|
||||||
|
// اگر سرپرست پروژهای نداشته باشد، دسترسی مسدود میشود
|
||||||
|
if (!user.projects || user.projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
||||||
|
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
|
||||||
|
<p className="mt-2 text-[12.5px] leading-6 text-muted">
|
||||||
|
شما به عنوان سرپرست پروژه در سیستم ثبت شدهاید اما هنوز هیچ پروژهای به شما تخصیص داده نشده است.
|
||||||
|
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}}
|
||||||
|
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
|
||||||
|
>
|
||||||
|
خروج از حساب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
|
||||||
|
if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") {
|
||||||
|
return <Navigate to="/panel/pm" replace />;
|
||||||
|
}
|
||||||
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// صفحهی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
|
// صفحهی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات
|
||||||
function HomeRoute() {
|
function HomeRoute() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
|
||||||
return <DashboardPage />;
|
return <DashboardPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین
|
||||||
|
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") {
|
||||||
|
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
||||||
|
function PmPanelPage() {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src="/pm/"
|
||||||
|
title="مدیریت پروژه (PM)"
|
||||||
|
className="h-dvh w-full border-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی پنل PM: همه نقشها دسترسی دارند
|
||||||
|
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
|
||||||
|
if (!allowed) return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
||||||
|
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
|
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمیتوانند موجودی تغییر دهند)
|
||||||
|
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
if (user?.role !== "admin" && user?.role !== "supervisor") {
|
||||||
|
return <Navigate to="/inventory" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط میتواند موجودی تغییر دهد)
|
||||||
|
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
if (user?.role === "supervisor") {
|
||||||
|
return <Navigate to="/inventory" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -84,7 +169,9 @@ export default function App() {
|
|||||||
path="/wizard"
|
path="/wizard"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
|
<RequireProcurementWrite>
|
||||||
<WizardPage />
|
<WizardPage />
|
||||||
|
</RequireProcurementWrite>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +251,9 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequirePmProjects>
|
||||||
|
<RequireInventoryChange>
|
||||||
<ChangeInventoryPage />
|
<ChangeInventoryPage />
|
||||||
|
</RequireInventoryChange>
|
||||||
</RequirePmProjects>
|
</RequirePmProjects>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
@@ -184,18 +273,24 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequirePmProjects>
|
||||||
|
<RequireWarehouseManage>
|
||||||
<WarehousesPage />
|
<WarehousesPage />
|
||||||
|
</RequireWarehouseManage>
|
||||||
</RequirePmProjects>
|
</RequirePmProjects>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/inventory/org"
|
path="/inventory/org"
|
||||||
|
element={<Navigate to="/organization" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/organization"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequireOrganization>
|
||||||
<OrganizationPage />
|
<OrganizationPage />
|
||||||
</RequirePmProjects>
|
</RequireOrganization>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -207,6 +302,17 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
|
||||||
|
<Route
|
||||||
|
path="/panel/pm"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequirePmPanel>
|
||||||
|
<PmPanelPage />
|
||||||
|
</RequirePmPanel>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,48 +1,72 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
||||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
|
||||||
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
|
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
|
||||||
// دقیقاً مثل منوی دفتر: عنوان «پنلها»، ماژول فعلی با استایل فعال (پر)
|
// ۱. مدیریت انبار (/inventory)
|
||||||
// + «✓ فعلی»، ماژول دیگر بهعنوان سوییچر، و در پایان جداکننده + خروج.
|
// ۲. مدیریت تدارکات (/)
|
||||||
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز میشود که همهی
|
// ۳. مدیریت پروژه (/panel/pm)
|
||||||
// پروژههای تخصیصیافته را نشان میدهد؛ پروژهی فعال با «✓ فعلی» مشخص است و
|
// ۴. سازماندهی (/organization)
|
||||||
// انتخاب هر پروژه فقط «پروژهی فعال» را عوض میکند (نشست/توکن دستنخورده).
|
|
||||||
export function AppSwitcherMenu({
|
export function AppSwitcherMenu({
|
||||||
activeModule,
|
activeModule,
|
||||||
canSwitchToProcurement = true,
|
canSwitchToProcurement = true,
|
||||||
}: {
|
}: {
|
||||||
activeModule: "warehouse" | "procurement";
|
activeModule: "warehouse" | "procurement" | "organization";
|
||||||
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
|
canSwitchToProcurement?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const { pathname } = useLocation();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setActiveProject = useAuthStore((s) => s.setActiveProject);
|
const isSupervisor = user?.role === "supervisor";
|
||||||
const items: MenuItem[] = [];
|
|
||||||
|
|
||||||
const isPm = user?.role === "pm";
|
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده میشود
|
||||||
const pmProjects = isPm ? user?.projects ?? [] : [];
|
if (isSupervisor) {
|
||||||
const projectChildren: MenuItem[] | undefined =
|
const supervisorItems: MenuItem[] = [
|
||||||
activeModule === "warehouse" && isPm
|
{
|
||||||
? pmProjects.map((p) => ({
|
label: "انبار",
|
||||||
label: p.name,
|
to: "/inventory",
|
||||||
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض میشود
|
active: activeModule === "warehouse",
|
||||||
active: p.id === user?.project_id,
|
},
|
||||||
onClick: () => setActiveProject(p.id),
|
{
|
||||||
}))
|
label: "مدیریت پروژه",
|
||||||
: undefined;
|
to: "/panel/pm",
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{ label: "خروج از حساب", onClick: logout },
|
||||||
|
];
|
||||||
|
return <PanelMenu title="پنلها" items={supervisorItems} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{
|
||||||
|
label: "انبار",
|
||||||
|
to: "/inventory",
|
||||||
|
active: activeModule === "warehouse",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (activeModule === "procurement") {
|
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
|
||||||
} else {
|
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
|
|
||||||
if (canSwitchToProcurement) {
|
if (canSwitchToProcurement) {
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
items.push({
|
||||||
|
label: "تدارکات",
|
||||||
|
to: "/",
|
||||||
|
active: activeModule === "procurement",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: "مدیریت پروژه",
|
||||||
|
to: "/panel/pm",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canManage) {
|
||||||
|
items.push({
|
||||||
|
label: "سازماندهی",
|
||||||
|
to: "/organization",
|
||||||
|
active: activeModule === "organization",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
items.push({ label: "خروج از حساب", onClick: logout });
|
items.push({ label: "خروج از حساب", onClick: logout });
|
||||||
return <PanelMenu title="پنلها" items={items} />;
|
return <PanelMenu title="پنلها" items={items} />;
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
|
|||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 w-full my-2">
|
<div className="flex items-start gap-2 w-full my-2">
|
||||||
{/* کادر انتخاب تاریخ */}
|
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||||
<div className="w-36 shrink-0">
|
<div className="w-28 shrink-0">
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={milestone.when_iso}
|
value={milestone.when_iso}
|
||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export interface NumberInputProps {
|
|||||||
onChange: (n: number | null) => void;
|
onChange: (n: number | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیشفرض: راستچین)
|
||||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||||
inputMode?: "numeric" | "decimal" | undefined;
|
inputMode?: "numeric" | "decimal" | undefined;
|
||||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
maxDecimals?: number; // حداکثر رقم اعشار
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export function NumberInput({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
|
inputClassName,
|
||||||
|
align = "right",
|
||||||
showWords = false,
|
showWords = false,
|
||||||
inputMode = "numeric",
|
inputMode = "numeric",
|
||||||
maxDecimals,
|
maxDecimals,
|
||||||
@@ -36,8 +40,6 @@ export function NumberInput({
|
|||||||
|
|
||||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||||
|
|
||||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
|
||||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cur = parseNum(text);
|
const cur = parseNum(text);
|
||||||
if (value != null && value !== cur) {
|
if (value != null && value !== cur) {
|
||||||
@@ -60,19 +62,28 @@ export function NumberInput({
|
|||||||
|
|
||||||
const n = parseNum(text);
|
const n = parseNum(text);
|
||||||
|
|
||||||
|
const alignClass =
|
||||||
|
align === "center"
|
||||||
|
? "!text-center text-center"
|
||||||
|
: align === "left"
|
||||||
|
? "!text-left text-left"
|
||||||
|
: "!text-right text-right";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-w-0", className)}>
|
<div className={cn("min-w-0 w-full", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className={cn(
|
||||||
|
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
alignClass,
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value;
|
const raw = e.target.value;
|
||||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
|
||||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
|
||||||
const parsed = parseNum(raw);
|
const parsed = parseNum(raw);
|
||||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||||
if (parsed != null && !trailingSep) {
|
if (parsed != null && !trailingSep) {
|
||||||
@@ -85,16 +96,11 @@ export function NumberInput({
|
|||||||
}}
|
}}
|
||||||
onBlur={() => commit(text)}
|
onBlur={() => commit(text)}
|
||||||
/>
|
/>
|
||||||
{showWords && (
|
{showWords && n != null && (
|
||||||
<span className={cn("num-pill", n == null && "empty")}>
|
<div className="mt-1 block text-right text-[11px] text-muted">
|
||||||
{n != null ? (
|
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
|
||||||
<>
|
<span className="text-muted">({toWords(n)})</span>
|
||||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
|
|||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
label?: string;
|
label?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز میشود
|
||||||
|
href?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : it.href ? (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={it.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||||
|
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
<span aria-hidden className="text-[10px] opacity-60">↗</span>
|
||||||
|
</a>
|
||||||
) : it.to ? (
|
) : it.to ? (
|
||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
|||||||
const setProjects = useAuthStore((s) => s.setProjects);
|
const setProjects = useAuthStore((s) => s.setProjects);
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
|
||||||
const isPm = user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role !== "pm") {
|
if (!user || user.role !== "supervisor") {
|
||||||
setState("ok");
|
setState("ok");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight, ClipboardList } from "lucide-react";
|
import { ChevronRight, ClipboardList } from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useAuthStore, type AuthUser } from "../../store/auth";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function userSubtitle(user: AuthUser | null | undefined): string {
|
||||||
|
if (!user) return "";
|
||||||
|
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
|
||||||
|
const username = user.username || user.casdoor_id || "-";
|
||||||
|
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
||||||
|
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
|
||||||
|
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,6 +35,10 @@ interface HeaderProps {
|
|||||||
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const userSub = userSubtitle(user);
|
||||||
|
const displaySub = sub !== undefined ? sub : userSub;
|
||||||
|
|
||||||
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
||||||
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
||||||
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
|||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
||||||
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
{displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
PackageSearch,
|
PackageSearch,
|
||||||
Scale,
|
Scale,
|
||||||
Truck,
|
Truck,
|
||||||
UsersRound,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
|
|||||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||||
];
|
];
|
||||||
|
|
||||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات (آینهی ناوبری بالای دسکتاپ)
|
// تبهای پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینهی ناوبری بالای دسکتاپ)
|
||||||
const ADMIN_INV_TABS: TabDef[] = [
|
const ADMIN_INV_TABS: TabDef[] = [
|
||||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||||
|
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
|
||||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||||
{ to: "/inventory/org", label: "سازماندهی", icon: UsersRound },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
||||||
@@ -81,6 +80,10 @@ export function MobileTabBar() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
if (pathname === "/login" || pathname === "/admin") return null;
|
if (pathname === "/login" || pathname === "/admin") return null;
|
||||||
|
// پنل مدیریت پروژه: iframe تمامصفحهی PM است و خودش تببار داخلی دارد
|
||||||
|
if (pathname === "/panel/pm") return null;
|
||||||
|
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
|
||||||
|
if (role === "supervisor") return null;
|
||||||
const isPM = role === "pm";
|
const isPM = role === "pm";
|
||||||
const isOffice = role === "admin" || role === "procurement_manager";
|
const isOffice = role === "admin" || role === "procurement_manager";
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
|
|||||||
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
||||||
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
||||||
export { useMaterials, useAddMaterial } from "./useMaterials";
|
export { useMaterials, useAddMaterial } from "./useMaterials";
|
||||||
export { useProjects, useAddProject } from "./useProjects";
|
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
|
||||||
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
||||||
|
|||||||
@@ -21,3 +21,15 @@ export function useAddProject() {
|
|||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeleteProject() {
|
||||||
|
const deleteProject = useAppStore((s) => s.deleteProject);
|
||||||
|
const invalidate = useInvalidateStore();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.projects.delete(id),
|
||||||
|
onMutate: (id) => deleteProject(id),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+44
-9
@@ -87,7 +87,7 @@ function handle401(path: string, res: Response): void {
|
|||||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||||
function activeProjectId(): string | null {
|
function activeProjectId(): string | null {
|
||||||
const { user } = useAuthStore.getState();
|
const { user } = useAuthStore.getState();
|
||||||
return user?.role === "pm" ? user.project_id : null;
|
return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectQuery(projectId?: string | null): string {
|
function projectQuery(projectId?: string | null): string {
|
||||||
@@ -323,6 +323,12 @@ interface RawSupplier {
|
|||||||
interface RawProject {
|
interface RawProject {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
location?: string | null;
|
||||||
|
project_manager?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
progress?: number | null;
|
||||||
}
|
}
|
||||||
interface RawSpec {
|
interface RawSpec {
|
||||||
attribute_key: string;
|
attribute_key: string;
|
||||||
@@ -1131,11 +1137,36 @@ export const api = {
|
|||||||
projects: {
|
projects: {
|
||||||
list: async (): Promise<Project[]> => {
|
list: async (): Promise<Project[]> => {
|
||||||
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
|
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
|
||||||
return raw.map((p) => ({ id: p.id, name: p.name }));
|
return raw.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
location: p.location || "",
|
||||||
|
project_manager: p.project_manager || "",
|
||||||
|
description: p.description || "",
|
||||||
|
start_date: p.start_date,
|
||||||
|
status: p.status || "ACTIVE",
|
||||||
|
progress: p.progress != null ? Number(p.progress) : 0,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
create: async (p: Project): Promise<Project> => {
|
create: async (p: Project): Promise<Project> => {
|
||||||
const raw = await postData<RawProject>("/projects", { name: p.name });
|
const raw = await postData<RawProject>("/projects", {
|
||||||
return { id: raw.id, name: raw.name };
|
name: p.name,
|
||||||
|
location: p.location || "",
|
||||||
|
project_manager: p.project_manager || "",
|
||||||
|
description: p.description || "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
location: raw.location || "",
|
||||||
|
project_manager: raw.project_manager || "",
|
||||||
|
description: raw.description || "",
|
||||||
|
status: raw.status || "ACTIVE",
|
||||||
|
progress: raw.progress != null ? Number(raw.progress) : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await deleteData(`/projects/${id}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1525,10 +1556,11 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
|
|||||||
export interface UserRow {
|
export interface UserRow {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string | null;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
@@ -1553,10 +1585,11 @@ export const apiV26 = {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
@@ -1570,6 +1603,7 @@ export const apiV26 = {
|
|||||||
expires_in: res.data.expires_in,
|
expires_in: res.data.expires_in,
|
||||||
user: {
|
user: {
|
||||||
...u,
|
...u,
|
||||||
|
username: u.username || u.casdoor_id,
|
||||||
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1589,14 +1623,15 @@ export const apiV26 = {
|
|||||||
users: {
|
users: {
|
||||||
me: async (): Promise<import("../store/auth").AuthUser> => {
|
me: async (): Promise<import("../store/auth").AuthUser> => {
|
||||||
const data = await getData<{
|
const data = await getData<{
|
||||||
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
|
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
|
||||||
project_id: string | null; project_name: string | null;
|
project_id: string | null; project_name: string | null;
|
||||||
projects?: { id: string; name: string }[];
|
projects?: { id: string; name: string }[];
|
||||||
}>("/users/me");
|
}>("/users/me");
|
||||||
return {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
casdoor_id: data.casdoor_id,
|
casdoor_id: data.casdoor_id,
|
||||||
|
username: data.username || data.casdoor_id,
|
||||||
first_name: data.first_name,
|
first_name: data.first_name,
|
||||||
last_name: data.last_name,
|
last_name: data.last_name,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
@@ -1620,7 +1655,7 @@ export const apiV26 = {
|
|||||||
first_name: string; last_name?: string; phone?: string | null;
|
first_name: string; last_name?: string; phone?: string | null;
|
||||||
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
||||||
}): Promise<UserRow> => postData<UserRow>("/users", body),
|
}): Promise<UserRow> => postData<UserRow>("/users", body),
|
||||||
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
|
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
|
||||||
patchData<UserRow>(`/users/${id}`, { role }),
|
patchData<UserRow>(`/users/${id}`, { role }),
|
||||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export interface Material {
|
|||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
project_manager?: string;
|
||||||
|
description?: string;
|
||||||
|
start_date?: string | null;
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحلهی پرداخت
|
// مرحلهی پرداخت
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
pm: "مدیر پروژه",
|
pm: "مدیر پروژه",
|
||||||
procurement_manager: "مدیر تدارکات",
|
procurement_manager: "مدیر تدارکات",
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
||||||
@@ -80,6 +81,26 @@ export function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
|
||||||
|
const toggleSupervisor = async (u: UserRow) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
const target = u.role === "supervisor" ? null : "supervisor";
|
||||||
|
try {
|
||||||
|
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
|
||||||
|
toast.success(
|
||||||
|
target === "supervisor"
|
||||||
|
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
|
||||||
|
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -129,12 +150,13 @@ export function AdminPanel() {
|
|||||||
<th className="py-2 pe-3">نام کامل</th>
|
<th className="py-2 pe-3">نام کامل</th>
|
||||||
<th className="py-2 pe-3">شناسه Casdoor</th>
|
<th className="py-2 pe-3">شناسه Casdoor</th>
|
||||||
<th className="py-2 pe-3">نقش فعلی</th>
|
<th className="py-2 pe-3">نقش فعلی</th>
|
||||||
<th className="py-2">مدیریت مدیر تدارکات</th>
|
<th className="py-2">مدیریت نقشها</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const isPMgr = u.role === "procurement_manager";
|
const isPMgr = u.role === "procurement_manager";
|
||||||
|
const isSup = u.role === "supervisor";
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
<tr key={u.id} className="border-b border-line/50">
|
||||||
<td className="py-2 pe-3 font-semibold">
|
<td className="py-2 pe-3 font-semibold">
|
||||||
@@ -147,6 +169,7 @@ export function AdminPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
disabled={busyId === u.id}
|
disabled={busyId === u.id}
|
||||||
onClick={() => void toggleProcurementManager(u)}
|
onClick={() => void toggleProcurementManager(u)}
|
||||||
@@ -158,6 +181,18 @@ export function AdminPanel() {
|
|||||||
>
|
>
|
||||||
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onClick={() => void toggleSupervisor(u)}
|
||||||
|
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||||
|
isSup
|
||||||
|
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
|
||||||
|
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,14 +77,18 @@ export function LoginPage() {
|
|||||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
|
||||||
|
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
||||||
track("login_success", { role: user.role });
|
track("login_success", { role: user.role });
|
||||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقشها → دفتر پیشنهادهای خرید
|
||||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
navigate(user.role === "supervisor" ? "/panel/pm" : "/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
|
|||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { AcceptModal } from "./AcceptModal";
|
import { AcceptModal } from "./AcceptModal";
|
||||||
import { EditModal } from "./EditModal";
|
import { EditModal } from "./EditModal";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
|
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
@@ -85,7 +89,7 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-20 md:pb-6">
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
<Header
|
<Header
|
||||||
title="دفتر پیشنهادهای خرید"
|
title="مدیریت تدارکات"
|
||||||
brand
|
brand
|
||||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
actions={
|
actions={
|
||||||
@@ -114,6 +118,7 @@ export function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||||
|
{canWrite && (
|
||||||
<Link
|
<Link
|
||||||
to="/wizard"
|
to="/wizard"
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
||||||
@@ -121,6 +126,7 @@ export function DashboardPage() {
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن پیشنهاد جدید</span>
|
<span>افزودن پیشنهاد جدید</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -148,10 +154,12 @@ export function DashboardPage() {
|
|||||||
onToggleExpand={toggleExpand}
|
onToggleExpand={toggleExpand}
|
||||||
onAccept={setAcceptId}
|
onAccept={setAcceptId}
|
||||||
onEdit={setEditId}
|
onEdit={setEditId}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
||||||
|
{canWrite && (
|
||||||
<Link
|
<Link
|
||||||
to="/wizard"
|
to="/wizard"
|
||||||
aria-label="افزودن پیشنهاد جدید"
|
aria-label="افزودن پیشنهاد جدید"
|
||||||
@@ -159,6 +167,7 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="h-7 w-7" />
|
<Plus className="h-7 w-7" />
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { FileList } from "../../components/FileList";
|
import { FileList } from "../../components/FileList";
|
||||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||||
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||||
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
|
|
||||||
// دسته روی تأمینکننده است و دیگر برچسبِ این فیلد نیست)
|
|
||||||
cat: m.name || "",
|
cat: m.name || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
.list(quoteId, "invoice")
|
.list(quoteId, "invoice")
|
||||||
.then((list) => alive && setInvoices(list))
|
.then((list) => alive && setInvoices(list))
|
||||||
.catch(() => alive && setInvoices([]));
|
.catch(() => alive && setInvoices([]));
|
||||||
|
void api.files
|
||||||
|
.list(quoteId, "proforma")
|
||||||
|
.then((list) => alive && setProformas(list))
|
||||||
|
.catch(() => alive && setProformas([]));
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [quoteId]);
|
}, [quoteId]);
|
||||||
|
|
||||||
if (!q || !ready) return null;
|
const itemOptions = useMemo(() => {
|
||||||
|
return itemRows
|
||||||
|
.filter((r) => !r.rejected || r.restore)
|
||||||
|
.map((r, idx) => ({
|
||||||
|
tempId: r.key,
|
||||||
|
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
|
||||||
|
}));
|
||||||
|
}, [itemRows]);
|
||||||
|
|
||||||
const tsLabel = (card: EditTsCard, idx: number) => {
|
const getCoveredIds = (card: EditTsCard) => {
|
||||||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
const cardId = card.domId || card.key;
|
||||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
const explicit = itemRows
|
||||||
|
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
|
||||||
|
.map((r) => r.key);
|
||||||
|
|
||||||
|
if (!explicit.length && tsCards[0]?.key === card.key) {
|
||||||
|
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
|
||||||
|
}
|
||||||
|
return explicit;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
|
||||||
|
const card = tsCards.find((c) => c.key === cardKey);
|
||||||
|
const cardId = card?.domId || cardKey;
|
||||||
|
setItemRows((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const shouldHave = newCoveredKeys.includes(row.key);
|
||||||
|
const exists = row.tsDomIds.includes(cardId);
|
||||||
|
if (shouldHave === exists) return row;
|
||||||
|
const nextTsDomIds = shouldHave
|
||||||
|
? [...row.tsDomIds, cardId]
|
||||||
|
: row.tsDomIds.filter((id) => id !== cardId);
|
||||||
|
return { ...row, tsDomIds: nextTsDomIds };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!q || !ready) return null;
|
||||||
|
|
||||||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addItemRow = () => {
|
const addItemRow = () => {
|
||||||
|
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
|
||||||
|
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
|
||||||
setItemRows((rows) => [
|
setItemRows((rows) => [
|
||||||
...rows,
|
...rows,
|
||||||
{
|
{
|
||||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
key: newKey,
|
||||||
cat: "",
|
cat: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
specs: [],
|
specs: [],
|
||||||
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
unit: "",
|
unit: "",
|
||||||
price: null,
|
price: null,
|
||||||
total: null,
|
total: null,
|
||||||
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
tsDomIds: defaultTsId ? [defaultTsId] : [],
|
||||||
acceptedTsId: null,
|
acceptedTsId: null,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
restore: true,
|
restore: true,
|
||||||
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const addTsCard = () => {
|
const addTsCard = () => {
|
||||||
setTsCards((cards) => [
|
setTsCards((cards) => [
|
||||||
...cards,
|
...cards,
|
||||||
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
{
|
||||||
|
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
|
||||||
|
domId: "",
|
||||||
|
milestones: [{ when_iso: null, amount: null }],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTsCard = (key: string) => {
|
const removeTsCard = (key: string) => {
|
||||||
|
if (tsCards.length <= 1) {
|
||||||
|
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
||||||
|
const card = tsCards.find((c) => c.key === key);
|
||||||
|
const cardId = card?.domId || key;
|
||||||
|
setItemRows((rows) =>
|
||||||
|
rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
|
||||||
|
})),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
next.projects = [...selectedProjects];
|
next.projects = [...selectedProjects];
|
||||||
next.notes = notes.trim();
|
next.notes = notes.trim();
|
||||||
next.invoices = invoices;
|
next.invoices = invoices;
|
||||||
|
next.proformas = proformas;
|
||||||
|
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
? domId
|
? domId
|
||||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||||
tsIdMap[domId] = realId;
|
tsIdMap[domId] = realId;
|
||||||
|
tsIdMap[card.key] = realId;
|
||||||
const orig = next.termsheets.find((t) => t.id === realId);
|
const orig = next.termsheets.find((t) => t.id === realId);
|
||||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||||
const st = orig?.status || "offered";
|
const st = orig?.status || "offered";
|
||||||
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const total = row.total;
|
const total = row.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
// هویت متریال = نام + برند + مشخصه (دیگر دستهای برای تطبیق وجود ندارد)
|
|
||||||
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material) {
|
||||||
@@ -322,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
if (!rejected) {
|
if (!rejected) {
|
||||||
row.tsDomIds.forEach((domId) => {
|
row.tsDomIds.forEach((domId) => {
|
||||||
const realId = tsIdMap[domId] || domId;
|
const realId = tsIdMap[domId] || domId;
|
||||||
if (realId) termsheet_ids.push(realId);
|
if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
|
||||||
});
|
});
|
||||||
if (!termsheet_ids.length && next.termsheets.length) {
|
if (!termsheet_ids.length && next.termsheets.length) {
|
||||||
termsheet_ids.push(next.termsheets[0].id);
|
termsheet_ids.push(next.termsheets[0].id);
|
||||||
@@ -359,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
const referencedTs = new Set<string>();
|
const referencedTs = new Set<string>();
|
||||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
@@ -406,7 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<>
|
<>
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
||||||
{/* هدر سفید و تمیز */}
|
{/* هدر */}
|
||||||
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||||
@@ -421,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تببار سفید و استاندارد */}
|
{/* تببار */}
|
||||||
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -492,12 +546,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
{/* محتوا */}
|
||||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||||
{/* تب ۱: اقلام و مشخصات */}
|
{/* تب ۱: اقلام */}
|
||||||
{activeTab === "items" && (
|
{activeTab === "items" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* کارت تأمینکننده و پروژه */}
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
@@ -536,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لیست اقلام */}
|
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||||
@@ -573,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
{/* ردیف ۱: متریال و برند */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">متریال</label>
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
placeholder="نام متریال"
|
placeholder="نام متریال"
|
||||||
value={row.cat}
|
value={row.cat}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
value={row.brand}
|
value={row.brand}
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
@@ -597,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
{/* ردیف ۲: مشخصه فنی (تمامعرض با فضای کافی برای تگها) */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
values={row.specs}
|
values={row.specs}
|
||||||
onChange={(specs) => patchItem(row.key, { specs })}
|
onChange={(specs) => patchItem(row.key, { specs })}
|
||||||
options={specOptions}
|
options={specOptions}
|
||||||
placeholder="مشخصه"
|
placeholder="انتخاب مشخصات..."
|
||||||
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
|
||||||
<input
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
disabled={hasReceipts}
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
placeholder="واحد سنجش"
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
|
||||||
value={row.unit}
|
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* جعبه ۳ عددی مقادیر */}
|
|
||||||
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.qty}
|
value={row.qty}
|
||||||
onChange={(qty) => {
|
onChange={(qty) => {
|
||||||
patchItem(row.key, {
|
patchItem(row.key, {
|
||||||
qty,
|
qty,
|
||||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="تعداد"
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۴: قیمت هر واحد (تمامعرض با نمایش حروف) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
|
||||||
|
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.price}
|
value={row.price}
|
||||||
onChange={(price) => {
|
onChange={(price) => {
|
||||||
patchItem(row.key, {
|
patchItem(row.key, {
|
||||||
price,
|
price,
|
||||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="واحد"
|
placeholder="قیمت هر واحد"
|
||||||
|
showWords
|
||||||
maxDecimals={2}
|
maxDecimals={2}
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۵: قیمت کل این قلم (تمامعرض با نمایش حروف) */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
@@ -666,14 +730,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="جمع"
|
placeholder="قیمت کل قلم"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* شرایط پرداخت برای این قلم */}
|
{row.rejected && (
|
||||||
<div className="pt-2 border-t border-gray-100">
|
<div className="pt-2 border-t border-gray-100">
|
||||||
{row.rejected ? (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -683,46 +748,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
بازگرداندن این قلم به معامله
|
بازگرداندن این قلم به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{tsCards.map((card, cIdx) => {
|
|
||||||
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={card.key}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
|
||||||
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
|
||||||
hasReceipts && "cursor-not-allowed opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = new Set(row.tsDomIds);
|
|
||||||
const domId = card.domId || card.key;
|
|
||||||
if (e.target.checked) next.add(domId);
|
|
||||||
else next.delete(domId);
|
|
||||||
patchItem(row.key, { tsDomIds: [...next] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{tsLabel(card, cIdx)}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
@@ -741,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تب ۲: شرایط پرداخت */}
|
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
|
||||||
{activeTab === "terms" && (
|
{activeTab === "terms" && (
|
||||||
<div className="space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||||
|
<div className="text-[11.5px] text-gray-500 mt-0.5">
|
||||||
|
روشهای پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -762,72 +796,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, idx) => (
|
||||||
<div
|
<TermsheetCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
index={idx + 1}
|
||||||
>
|
milestones={card.milestones}
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
|
||||||
<div className="flex items-center gap-1.5">
|
items={itemOptions}
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
coveredIds={getCoveredIds(card)}
|
||||||
{faNum(idx + 1)}
|
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
|
||||||
</span>
|
onRemove={() => removeTsCard(card.key)}
|
||||||
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
|
|
||||||
<span className="text-[11px] text-gray-500 font-medium">
|
|
||||||
({faNum(card.milestones.length)} مرحله)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!hasReceipts && tsCards.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
|
||||||
title="حذف گزینه"
|
|
||||||
onClick={() => removeTsCard(card.key)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[11px]">حذف</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
|
||||||
{card.milestones.map((ms, i) => (
|
|
||||||
<MilestoneRow
|
|
||||||
key={i}
|
|
||||||
milestone={ms}
|
|
||||||
disabled={hasReceipts}
|
|
||||||
onChange={(nextMs) =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onRemove={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.filter((_, j) => j !== i),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasReceipts && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="line"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto text-xs text-gray-700"
|
|
||||||
onClick={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 me-1" />
|
|
||||||
افزودن مرحله پرداخت
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -891,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* پیشفاکتورها — در همهی مراحل چرخهی پیشنهاد قابل افزودن/ویرایش است */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||||
|
<div className="text-xs font-bold text-gray-900">پیشفاکتورها</div>
|
||||||
|
<FileList
|
||||||
|
files={proformas}
|
||||||
|
onChange={setProformas}
|
||||||
|
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||||
|
onDelete={(a) =>
|
||||||
|
a.id &&
|
||||||
|
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
||||||
|
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
|
||||||
|
label="افزودن پیشفاکتور"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر سفید، محکم و چسبان */}
|
{/* فوتر */}
|
||||||
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
||||||
{q.status === "offered" ? (
|
{q.status === "offered" ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -924,7 +923,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* مودال تایید حذف سفید */}
|
{/* مودال تایید حذف */}
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
|
|||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
onAccept: (id: string) => void;
|
onAccept: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
||||||
@@ -20,6 +21,7 @@ export function LedgerTable({
|
|||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onAccept,
|
onAccept,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
canWrite = true,
|
||||||
}: LedgerTableProps) {
|
}: LedgerTableProps) {
|
||||||
if (!quotes.length) {
|
if (!quotes.length) {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +42,7 @@ export function LedgerTable({
|
|||||||
onToggleExpand={() => onToggleExpand(q.id)}
|
onToggleExpand={() => onToggleExpand(q.id)}
|
||||||
onAccept={() => onAccept(q.id)}
|
onAccept={() => onAccept(q.id)}
|
||||||
onEdit={() => onEdit(q.id)}
|
onEdit={() => onEdit(q.id)}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
|
|||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||||
@@ -35,8 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت پیشنهاد خرید — بهینهشده برای کاربری سرپا با موبایل
|
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
|
||||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
|
||||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||||
const expired = isExpired(q);
|
const expired = isExpired(q);
|
||||||
const total = quoteTotal(q);
|
const total = quoteTotal(q);
|
||||||
@@ -47,14 +47,19 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
// ستون / دکمه پذیرش
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
if (q.status === "offered") {
|
if (q.status === "offered") {
|
||||||
acceptedCell = (
|
acceptedCell = canWrite ? (
|
||||||
<button
|
<button
|
||||||
onClick={onAccept}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAccept();
|
||||||
|
}}
|
||||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
<span>پذیرش</span>
|
<span>پذیرش</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
|
||||||
);
|
);
|
||||||
} else if (q.accepted_date) {
|
} else if (q.accepted_date) {
|
||||||
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
||||||
@@ -134,53 +139,83 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* سربرگ کارت */}
|
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند میشود */}
|
||||||
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
|
<div
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
|
||||||
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||||
{sup.name.slice(0, 1)}
|
{sup.name.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
{/* خط ۱: فقط خود اسم تأمینکننده لینک است */}
|
||||||
|
<div className="flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
to={`/suppliers/${q.supplier_id}`}
|
||||||
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
|
||||||
>
|
>
|
||||||
{sup.name}
|
{sup.name}
|
||||||
</Link>
|
</Link>
|
||||||
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
</div>
|
||||||
<span
|
|
||||||
className={cn(
|
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||||
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||||
q.is_official
|
{sup.location && (
|
||||||
? "bg-green-bg text-green"
|
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
||||||
: "bg-slate-bg text-muted",
|
{sup.location}
|
||||||
)}
|
|
||||||
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
|
||||||
>
|
|
||||||
{q.is_official ? "رسمی" : "غیررسمی"}
|
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.is_official && (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||||
|
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
|
||||||
|
<span>فاکتور رسمی</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
|
||||||
|
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||||
{fulfillmentBadge ?? (
|
{fulfillmentBadge ?? (
|
||||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||||
{STATUS_LABEL[q.status]}
|
{STATUS_LABEL[q.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* دکمه ویرایش */}
|
||||||
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
type="button"
|
||||||
onClick={onEdit}
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
title="ویرایش پیشنهاد"
|
title="ویرایش پیشنهاد"
|
||||||
aria-label="ویرایش"
|
aria-label="ویرایش"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3 text-muted" />
|
||||||
<span className="hidden sm:inline">ویرایش</span>
|
<span className="hidden sm:inline">ویرایش</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمه فلش اکسپند در هدر */}
|
||||||
<button
|
<button
|
||||||
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
type="button"
|
||||||
onClick={onToggleExpand}
|
className={cn(
|
||||||
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
|
||||||
|
expanded && "bg-accent-bg text-accent-dark border-accent/40",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleExpand();
|
||||||
|
}}
|
||||||
|
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||||
@@ -188,9 +223,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند میکند */}
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
<div
|
||||||
{/* ۱. پروژه — تمامعرض در موبایل */}
|
onClick={onToggleExpand}
|
||||||
|
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
|
||||||
|
>
|
||||||
|
{/* ۱. پروژه */}
|
||||||
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
@@ -200,7 +238,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
{/* ۲. اقلام */}
|
||||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||||
{activeItems.length > 0 ? (
|
{activeItems.length > 0 ? (
|
||||||
@@ -221,6 +259,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<Link
|
<Link
|
||||||
key={w.unit}
|
key={w.unit}
|
||||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||||
>
|
>
|
||||||
@@ -230,6 +269,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
|
{/* ۳. مجموع مبلغ */}
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -286,8 +326,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جزئیات تکمیلی بازشونده */}
|
{/* دکمه تمامعرض توضیحات بیشتر */}
|
||||||
{expanded && <QuoteDetail store={store} q={q} />}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
|
||||||
|
>
|
||||||
|
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
|
||||||
|
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته میشود */}
|
||||||
|
{expanded && (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<QuoteDetail store={store} q={q} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,9 @@ export function ChangeInventoryPage() {
|
|||||||
}
|
}
|
||||||
}, [params]);
|
}, [params]);
|
||||||
|
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isAdmin = user?.role === "admin";
|
||||||
const isPM = user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const canModify = isSupervisor || isAdmin;
|
||||||
|
|
||||||
const warehouses = data.warehouses;
|
const warehouses = data.warehouses;
|
||||||
const whOpts = useMemo(
|
const whOpts = useMemo(
|
||||||
@@ -401,7 +402,7 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
||||||
|
|
||||||
if (isAdmin) {
|
if (!canModify) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||||
@@ -409,22 +410,12 @@ export function ChangeInventoryPage() {
|
|||||||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||||||
<div className="text-3xl">🔒</div>
|
<div className="text-3xl">🔒</div>
|
||||||
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقطخواندنی</h3>
|
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقطخواندنی</h3>
|
||||||
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>
|
<p className="mt-1 text-[12.5px] text-muted">امکان تغییر موجودی انبار منحصراً در اختیار سرپرست پروژه و ادمین است.</p>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isPM) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCount = type === "STOCK_COUNT";
|
const isCount = type === "STOCK_COUNT";
|
||||||
const isTransfer = type === "TRANSFER";
|
const isTransfer = type === "TRANSFER";
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ export function InventoryPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
const isPM = user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const canModify = isSupervisor || isAdmin;
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||||
@@ -67,10 +69,10 @@ export function InventoryPage() {
|
|||||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||||
|
|
||||||
const visibleWarehouses = useMemo(() => {
|
const visibleWarehouses = useMemo(() => {
|
||||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||||
if (isAdmin) return warehouses;
|
if (isManager) return warehouses;
|
||||||
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
||||||
}, [warehouses, isAdmin, filters.project, user?.project_id]);
|
}, [warehouses, isManager, filters.project, user?.project_id]);
|
||||||
|
|
||||||
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
||||||
|
|
||||||
@@ -133,12 +135,12 @@ export function InventoryPage() {
|
|||||||
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen" />;
|
if (loading) return <div className="min-h-screen" />;
|
||||||
if (!user || (!isAdmin && !isPM)) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
|
<EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -150,10 +152,10 @@ export function InventoryPage() {
|
|||||||
title="سامانه مدیریت انبار"
|
title="سامانه مدیریت انبار"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
menu={<AppSwitcherMenu activeModule="warehouse" />}
|
||||||
actions={
|
actions={
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
<InventoryTopNav pathname={location.pathname} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -246,8 +248,8 @@ export function InventoryPage() {
|
|||||||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||||
{showMobileFilters && (
|
{showMobileFilters && (
|
||||||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||||
{/* پروژهها (مخصوص ادمین) */}
|
{/* پروژهها (مدیران و ادمین) */}
|
||||||
{isAdmin && lookup.projects.length > 0 && (
|
{isManager && lookup.projects.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -415,7 +417,7 @@ export function InventoryPage() {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور تغییر موجودی */}
|
{/* دکمه شناور تغییر موجودی */}
|
||||||
{isPM && (
|
{canModify && (
|
||||||
<Link
|
<Link
|
||||||
to="/inventory/change"
|
to="/inventory/change"
|
||||||
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
|
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاینسیستم دکمهها
|
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاینسیستم دکمهها
|
||||||
export function InventoryTopNav({
|
export function InventoryTopNav({
|
||||||
pathname,
|
pathname,
|
||||||
variant = "pm",
|
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
variant?: "pm" | "admin";
|
|
||||||
}) {
|
}) {
|
||||||
const items =
|
const user = useAuthStore((s) => s.user);
|
||||||
variant === "admin"
|
const isAdmin = user?.role === "admin";
|
||||||
? [
|
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
|
||||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
|
||||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
const items: { label: string; to: string; active: boolean }[] = [];
|
||||||
{ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") },
|
// سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده میکند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمیشود
|
||||||
]
|
if (isAdmin) {
|
||||||
: [
|
items.push({
|
||||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
label: "تغییر موجودی",
|
||||||
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
|
to: "/inventory/change",
|
||||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
active: pathname.startsWith("/inventory/change"),
|
||||||
];
|
});
|
||||||
|
}
|
||||||
|
if (isManager) {
|
||||||
|
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
||||||
|
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
||||||
|
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
|
||||||
import { inventorySubheader } from "./panelMenu";
|
|
||||||
import { apiV26, type UserRow } from "../../lib/api";
|
|
||||||
import { refreshProcurementStore } from "../../hooks";
|
|
||||||
import { useInventoryData } from "./useInventoryData";
|
|
||||||
import { faNum } from "../../lib/formatters";
|
|
||||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
|
||||||
|
|
||||||
type Role = "pm" | "procurement_manager" | "admin" | null;
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
pm: "مدیر پروژه",
|
|
||||||
procurement_manager: "مدیر تدارکات",
|
|
||||||
admin: "ادمین",
|
|
||||||
};
|
|
||||||
|
|
||||||
// سازماندهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
|
|
||||||
export function OrganizationPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const data = useInventoryData();
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
|
||||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
|
||||||
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
|
||||||
// فقط ادمین میتواند نقشها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
|
|
||||||
// پروژهی مدیران پروژه را تعیین/جابهجا میکند.
|
|
||||||
const canChangeRoles = user?.role === "admin";
|
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const list = await apiV26.users.list();
|
|
||||||
setUsers(list);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
|
||||||
} finally {
|
|
||||||
setLoadingUsers(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (canManage) void loadUsers();
|
|
||||||
}, [canManage, loadUsers]);
|
|
||||||
|
|
||||||
const syncUsers = async () => {
|
|
||||||
setSyncing(true);
|
|
||||||
try {
|
|
||||||
const { users: synced, created } = await apiV26.users.sync();
|
|
||||||
setUsers(synced);
|
|
||||||
toast.success(
|
|
||||||
created > 0
|
|
||||||
? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
|
|
||||||
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
|
||||||
);
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!canManage) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header title="سازماندهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- actions: users ----------
|
|
||||||
|
|
||||||
const changeRole = async (u: UserRow, role: Role) => {
|
|
||||||
setBusyId(u.id);
|
|
||||||
try {
|
|
||||||
await apiV26.users.updateRole(u.id, role);
|
|
||||||
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
|
|
||||||
await loadUsers();
|
|
||||||
refreshProcurementStore();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// پروژههای متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تکپروژهای
|
|
||||||
const assignedProjectsOf = (u: UserRow) =>
|
|
||||||
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
|
||||||
|
|
||||||
// همگامسازی چند-انتخابیِ پروژهها: افزودن/حذف هر اختلاف با یک PATCH
|
|
||||||
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین میشود)
|
|
||||||
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
|
|
||||||
const current = assignedProjectsOf(u).map((p) => p.id);
|
|
||||||
const added = nextIds.filter((id) => !current.includes(id));
|
|
||||||
const removed = current.filter((id) => !nextIds.includes(id));
|
|
||||||
if (!added.length && !removed.length) return;
|
|
||||||
setBusyId(u.id);
|
|
||||||
try {
|
|
||||||
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
|
||||||
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
|
||||||
const names = nextIds
|
|
||||||
.map((id) => data.lookup.projects.find((p) => p.id === id)?.name)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("، ");
|
|
||||||
toast.success(
|
|
||||||
nextIds.length
|
|
||||||
? `پروژههای «${u.first_name} ${u.last_name}» بهروزرسانی شد: ${names} ✓`
|
|
||||||
: `«${u.first_name} ${u.last_name}» از همهی پروژهها جدا شد`,
|
|
||||||
);
|
|
||||||
await loadUsers();
|
|
||||||
await data.refresh();
|
|
||||||
refreshProcurementStore();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
|
||||||
await loadUsers(); // وضعیت واقعی سرور را برگردان
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- actions: warehouses ----------
|
|
||||||
|
|
||||||
const addWarehouse = async (projectId: string) => {
|
|
||||||
const name = (newWh[projectId] || "").trim();
|
|
||||||
if (!name) return toast.error("نام انبار الزامی است");
|
|
||||||
try {
|
|
||||||
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
|
|
||||||
setNewWh((s) => ({ ...s, [projectId]: "" }));
|
|
||||||
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
|
|
||||||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
|
||||||
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
|
||||||
const removeWarehouse = async (id: string) => {
|
|
||||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
|
||||||
try {
|
|
||||||
await apiV26.warehouses.delete(id);
|
|
||||||
toast.success("انبار حذف شد");
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
|
|
||||||
const pmOf = (projectId: string) =>
|
|
||||||
users.find(
|
|
||||||
(u) =>
|
|
||||||
u.role === "pm" &&
|
|
||||||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header
|
|
||||||
title="سازماندهی"
|
|
||||||
sub={inventorySubheader(user)}
|
|
||||||
backTo="/inventory"
|
|
||||||
backLabel="بازگشت به داشبورد"
|
|
||||||
/>
|
|
||||||
<PageLayout className="flex flex-col gap-3">
|
|
||||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
|
||||||
ℹ️ کاربران از Casdoor همگام میشوند و نقشها اینجا (فقط توسط ادمین) تعیین میشوند؛
|
|
||||||
مدیر تدارکات فقط پروژهی مدیران پروژه را تعیین/جابهجا میکند. پروژهها از سیستم تدارکات میآیند و
|
|
||||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- کاربران و مدیران پروژه ---------- */}
|
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => void syncUsers()}
|
|
||||||
disabled={syncing}
|
|
||||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingUsers ? (
|
|
||||||
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
|
||||||
) : !users.length ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
|
||||||
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) بهصورت خودکار ساخته میشوند؛
|
|
||||||
یا دکمهی «همگامسازی از Casdoor» را بزنید.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-[12.5px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-line text-right text-muted">
|
|
||||||
<th className="py-2 pe-3">نام</th>
|
|
||||||
<th className="py-2 pe-3">نقش</th>
|
|
||||||
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
|
|
||||||
<th className="py-2">عملیات</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
|
||||||
<td className="py-1.5 pe-3">
|
|
||||||
<b>{u.first_name} {u.last_name}</b>
|
|
||||||
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pe-3">
|
|
||||||
{canChangeRoles ? (
|
|
||||||
<select
|
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
|
||||||
value={u.role ?? ""}
|
|
||||||
disabled={busyId === u.id}
|
|
||||||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
|
||||||
>
|
|
||||||
<option value="">— بدون نقش —</option>
|
|
||||||
<option value="pm">مدیر پروژه</option>
|
|
||||||
<option value="procurement_manager">مدیر تدارکات</option>
|
|
||||||
<option value="admin">ادمین</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
|
|
||||||
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!canChangeRoles && u.role && (
|
|
||||||
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="min-w-[240px] py-1.5 pe-3 align-top">
|
|
||||||
{u.role === "pm" ? (
|
|
||||||
<ProjectMultiSelect
|
|
||||||
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
|
|
||||||
values={assignedProjectsOf(u).map((p) => p.id)}
|
|
||||||
onChange={(ids) => void syncUserProjects(u, ids)}
|
|
||||||
disabled={busyId === u.id}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11.5px] text-muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5">
|
|
||||||
{u.role === "pm" && u.project_id && (
|
|
||||||
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mt-1 text-[11px] text-muted">
|
|
||||||
«بدون نقش» = حذف مدیر پروژه (از همهی پروژهها هم جدا میشود). هر مدیر پروژه میتواند به
|
|
||||||
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا میکند و اتصال به یک پروژه، مدیرِ
|
|
||||||
قبلیِ آن پروژه را جابهجا میکند.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- پروژهها و انبارها ---------- */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
||||||
{data.lookup.projects.map((p) => {
|
|
||||||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
|
||||||
const pm = pmOf(p.id);
|
|
||||||
return (
|
|
||||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
|
|
||||||
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
|
|
||||||
{faNum(whs.length)} انبار
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
|
||||||
{pm ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
|
||||||
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
|
|
||||||
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
|
||||||
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
|
|
||||||
{!whs.length ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-2 flex flex-col gap-1.5">
|
|
||||||
{whs.map((w) => (
|
|
||||||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<b>{w.name}</b>
|
|
||||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
|
||||||
<button
|
|
||||||
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
|
|
||||||
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
|
|
||||||
title="داشبورد این انبار"
|
|
||||||
>
|
|
||||||
📊 داشبورد
|
|
||||||
</button>
|
|
||||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<input
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
|
|
||||||
placeholder="نام انبار جدید"
|
|
||||||
value={newWh[p.id] || ""}
|
|
||||||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
|
|
||||||
placeholder="توضیحات (اختیاری)"
|
|
||||||
value={newWhDesc[p.id] || ""}
|
|
||||||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
|
|
||||||
+ افزودن انبار
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,52 +9,41 @@ import { useInventoryData, projName } from "./useInventoryData";
|
|||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
|
||||||
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد)
|
// انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
|
||||||
export function WarehousesPage() {
|
export function WarehousesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
const isPM = user?.role === "pm";
|
|
||||||
|
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newDesc, setNewDesc] = useState("");
|
const [newDesc, setNewDesc] = useState("");
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDesc, setEditDesc] = useState("");
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
|
||||||
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازماندهی»
|
if (!canManage) {
|
||||||
if (isAdmin) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||||||
<div className="text-3xl">🔒</div>
|
<div className="text-3xl">🔒</div>
|
||||||
<h3 className="mt-2 text-[15px] font-bold">دسترسی ندارد</h3>
|
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
|
||||||
<p className="mt-1 text-[12.5px] text-muted">
|
<p className="mt-1 text-[12.5px] text-muted">
|
||||||
بخش «انبارها» مخصوص مدیران پروژه است. مدیریت انبارها از بخش «سازماندهی» انجام میشود.
|
مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isPM) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const addWarehouse = async () => {
|
const addWarehouse = async () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (!name) return toast.error("نام انبار الزامی است");
|
if (!name) return toast.error("نام انبار الزامی است");
|
||||||
const projectId = user?.project_id;
|
const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id;
|
||||||
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
||||||
try {
|
try {
|
||||||
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
||||||
@@ -99,13 +88,26 @@ export function WarehousesPage() {
|
|||||||
backLabel="بازگشت به داشبورد"
|
backLabel="بازگشت به داشبورد"
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
{isPM && (
|
{canManage && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-3">
|
<div className="rounded-xl border border-line bg-panel p-3">
|
||||||
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{data.lookup.projects.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||||
|
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
>
|
||||||
|
{data.lookup.projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
پروژه: {p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||||
placeholder="نام انبار (مثلاً انبار موقت)"
|
placeholder="نام انبار (مثلاً انبار مرکزی)"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +150,7 @@ export function WarehousesPage() {
|
|||||||
>
|
>
|
||||||
📊 مشاهده داشبورد انبار
|
📊 مشاهده داشبورد انبار
|
||||||
</button>
|
</button>
|
||||||
{isPM && (
|
{canManage && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
||||||
@@ -162,7 +164,7 @@ export function WarehousesPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editId === w.id && isPM && (
|
{editId === w.id && canManage && (
|
||||||
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
|
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
|
|||||||
@@ -10,20 +10,22 @@ export function useLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
pm: "مدیر پروژه",
|
|
||||||
procurement_manager: "مدیر تدارکات",
|
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
// زیرعنوان پویای سربرگ پنل انبار:
|
// زیرعنوان پویای سربرگ پنل انبار:
|
||||||
// [نام کاربر] | [نقش] – [پروژه / همه پروژهها]
|
// [نام کاربر] | [نقش] – [پروژه / همه پروژهها]
|
||||||
// مدیر پروژه → نام پروژهی خودش؛ مدیر تدارکات/ادمین → «همه پروژهها».
|
// سرپرست پروژه → نام پروژهی خودش؛ مدیران/ادمین → «همه پروژهها».
|
||||||
export function inventorySubheader(user: { display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
|
export function inventorySubheader(user: { username?: string; casdoor_id?: string; display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
|
||||||
if (!user) return "در حال بارگذاری…";
|
if (!user) return "در حال بارگذاری…";
|
||||||
const name = user.display_name || `${user.first_name} ${user.last_name}`.trim() || "کاربر";
|
const name = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
|
||||||
|
const uname = user.username || user.casdoor_id || "-";
|
||||||
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
||||||
const proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژهها";
|
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
|
||||||
return `${name} | ${role} – ${proj}`;
|
return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخشها بالای صفحه هستند و
|
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخشها بالای صفحه هستند و
|
||||||
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
|
// منوی همبرگری سرپرست پروژه در پنل انبار
|
||||||
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمهی خروجِ تکی هم حذف شده).
|
|
||||||
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
||||||
const items: MenuItem[] = [];
|
const items: MenuItem[] = [];
|
||||||
if (onLogout) {
|
if (onLogout) {
|
||||||
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
// آیتمهای بخشهای داخلی انبار
|
||||||
//
|
|
||||||
// دسترسیها (v2.6):
|
|
||||||
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازماندهی —
|
|
||||||
// بدون «تغییر موجودی» و بدون «انبارها».
|
|
||||||
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
|
|
||||||
// بدون «سازماندهی».
|
|
||||||
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
||||||
const isAdmin = role === "admin" || role === "procurement_manager";
|
const isAdmin = role === "admin";
|
||||||
const isPM = role === "pm";
|
const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
|
||||||
|
const isSupervisor = role === "supervisor" || isAdmin;
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
|
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
|
||||||
];
|
];
|
||||||
if (isPM) {
|
// فقط سرپرست پروژه و ادمین میتوانند تغییر موجودی بدهند
|
||||||
|
if (isSupervisor) {
|
||||||
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
|
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
|
||||||
}
|
}
|
||||||
|
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران
|
||||||
|
if (isManager) {
|
||||||
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
||||||
if (isPM) {
|
|
||||||
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
||||||
}
|
|
||||||
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||||
if (isAdmin) {
|
|
||||||
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
|
||||||
}
|
|
||||||
if (isAdmin) {
|
|
||||||
items.push({ divider: true });
|
|
||||||
items.push({ label: "بازگشت به دفتر", to: "/" });
|
|
||||||
}
|
}
|
||||||
if (onLogout) {
|
if (onLogout) {
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export interface InventoryData {
|
|||||||
|
|
||||||
export function useInventoryData(): InventoryData {
|
export function useInventoryData(): InventoryData {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isPM = user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
const projectId = isPM ? user.project_id || undefined : undefined;
|
const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
|
||||||
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
import { apiV26, type UserRow } from "../../lib/api";
|
||||||
|
import { refreshProcurementStore, useProjects } from "../../hooks";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
|
|
||||||
|
type Role = "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
|
};
|
||||||
|
|
||||||
|
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها به سرپرستان
|
||||||
|
export function OrganizationPage() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const projects = useProjects();
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
|
// فقط ادمین میتواند نقشها را تغییر دهد؛ مدیران میتوانند پروژهها را به سرپرستان تخصیص دهند
|
||||||
|
const canChangeRoles = user?.role === "admin";
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await apiV26.users.list();
|
||||||
|
setUsers(list);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canManage) void loadUsers();
|
||||||
|
}, [canManage, loadUsers]);
|
||||||
|
|
||||||
|
const syncUsers = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const { users: synced, created } = await apiV26.users.sync();
|
||||||
|
setUsers(synced);
|
||||||
|
toast.success(
|
||||||
|
created > 0
|
||||||
|
? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
|
||||||
|
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||||||
|
);
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header title="سازماندهی" backTo="/" backLabel="بازگشت به دفتر" />
|
||||||
|
<PageLayout>
|
||||||
|
<div className="text-[13px] text-muted">این بخش فقط برای ادمین، مدیر تدارکات و مدیر پروژه است.</div>
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- actions: users ----------
|
||||||
|
|
||||||
|
const changeRole = async (u: UserRow, role: Role) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
try {
|
||||||
|
await apiV26.users.updateRole(u.id, role);
|
||||||
|
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
|
||||||
|
await loadUsers();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignedProjectsOf = (u: UserRow) =>
|
||||||
|
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
||||||
|
|
||||||
|
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
|
||||||
|
const current = assignedProjectsOf(u).map((p) => p.id);
|
||||||
|
const added = nextIds.filter((id) => !current.includes(id));
|
||||||
|
const removed = current.filter((id) => !nextIds.includes(id));
|
||||||
|
if (!added.length && !removed.length) return;
|
||||||
|
setBusyId(u.id);
|
||||||
|
try {
|
||||||
|
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
||||||
|
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
||||||
|
const names = nextIds
|
||||||
|
.map((id) => projects.find((p) => p.id === id)?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("، ");
|
||||||
|
toast.success(
|
||||||
|
nextIds.length
|
||||||
|
? `پروژههای «${u.first_name} ${u.last_name}» بهروزرسانی شد: ${names} ✓`
|
||||||
|
: `«${u.first_name} ${u.last_name}» از همهی پروژهها جدا شد`,
|
||||||
|
);
|
||||||
|
await loadUsers();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
||||||
|
await loadUsers();
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// سرپرستان تخصیصیافته به یک پروژه
|
||||||
|
const supervisorsOf = (projectId: string) =>
|
||||||
|
users.filter(
|
||||||
|
(u) =>
|
||||||
|
(u.role === "supervisor" || u.role === "pm") &&
|
||||||
|
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header
|
||||||
|
title="سازماندهی"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="organization" />}
|
||||||
|
/>
|
||||||
|
<PageLayout className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
|
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگامسازی» اضافه میشوند. نقشها در این پنل توسط ادمین تعیین میگردند.
|
||||||
|
مدیران پروژه و تدارکات میتوانند پروژهها را به سرپرستان پروژه تخصیص دهند.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- بخش ۱: جدول کاربران و نقشها ---------- */}
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[14px] font-bold">👥 کاربران و نقشها</h3>
|
||||||
|
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
تعیین نقش کاربران و تخصیص پروژهها به سرپرستان پروژه
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void syncUsers()}
|
||||||
|
disabled={syncing}
|
||||||
|
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||||
|
) : !users.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
||||||
|
کاربری ثبت نشده است — دکمهی «همگامسازی از Casdoor» را بزنید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[12.5px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line text-right text-muted">
|
||||||
|
<th className="py-2 pe-3">نام</th>
|
||||||
|
<th className="py-2 pe-3">نام کاربری</th>
|
||||||
|
<th className="py-2 pe-3">نقش</th>
|
||||||
|
<th className="py-2 pe-3">پروژههای تخصیصیافته (سرپرست پروژه)</th>
|
||||||
|
<th className="py-2">وضعیت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-line/50">
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
<b>{u.first_name} {u.last_name}</b>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
|
||||||
|
{u.username || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||||
|
value={u.role ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||||
|
>
|
||||||
|
<option value="">— بدون نقش —</option>
|
||||||
|
<option value="admin">ادمین</option>
|
||||||
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
|
<option value="pm">مدیر پروژه</option>
|
||||||
|
<option value="supervisor">سرپرست پروژه</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
|
||||||
|
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{u.role === "supervisor" || u.role === "pm" ? (
|
||||||
|
<div className="w-72">
|
||||||
|
<ProjectMultiSelect
|
||||||
|
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||||
|
values={assignedProjectsOf(u).map((p) => p.id)}
|
||||||
|
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
{u.role === "admin"
|
||||||
|
? "دسترسی کامل به همه پروژهها"
|
||||||
|
: u.role === "procurement_manager"
|
||||||
|
? "دسترسی کامل مدیریتی"
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-[11px] text-muted">
|
||||||
|
{busyId === u.id ? (
|
||||||
|
<span className="text-accent">در حال ذخیره…</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-emerald-600">✓ ثبتشده</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">
|
||||||
|
سرپرستان پروژهای که پروژهای به آنها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((p) => {
|
||||||
|
const sups = supervisorsOf(p.id);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
|
||||||
|
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
|
||||||
|
{p.status || "پروژه فعال"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.description && (
|
||||||
|
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
|
||||||
|
{!sups.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
|
||||||
|
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{sups.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
|
||||||
|
<span className="font-semibold">{s.first_name} {s.last_name}</span>
|
||||||
|
<span className="text-[10px] text-muted font-mono">({s.username})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
|
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban, MapPin, UserCircle2, Trash2, Plus, X } from "lucide-react";
|
||||||
import type { Project, Quote, Store } from "../../lib/types";
|
import type { Project, Quote, Store } from "../../lib/types";
|
||||||
import { nextId } from "../../lib/api";
|
|
||||||
import {
|
import {
|
||||||
useAddProject,
|
useAddProject,
|
||||||
|
useDeleteProject,
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
useQuotes,
|
useQuotes,
|
||||||
@@ -25,6 +25,7 @@ import { faNum, fmtDate } from "../../lib/formatters";
|
|||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||||
return quotes.filter(
|
return quotes.filter(
|
||||||
@@ -39,23 +40,14 @@ function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
|
||||||
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
|
||||||
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
|
||||||
// برای این پروژه همچنان «پیشنهاد شده» است.
|
|
||||||
function acceptedForProject(q: Quote, name: string): boolean {
|
function acceptedForProject(q: Quote, name: string): boolean {
|
||||||
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
|
||||||
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
|
||||||
function hasTargets(q: Quote): boolean {
|
function hasTargets(q: Quote): boolean {
|
||||||
return (q.line_items || []).some((li) => !!li.target_project);
|
return (q.line_items || []).some((li) => !!li.target_project);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اقلام مربوط به این پروژه در یک quote:
|
|
||||||
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
|
||||||
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
|
||||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
const targeted = items.filter((li) => li.target_project?.name === name);
|
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||||
@@ -73,6 +65,97 @@ function daysSince(iso: string | null): number | null {
|
|||||||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- مودال افزودن پروژه -----
|
||||||
|
function AddProjectModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (p: Omit<Project, "id">) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [projectManager, setProjectManager] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg border border-line bg-surface px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40";
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setName(""); setLocation(""); setProjectManager(""); setDescription(""); setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => { reset(); onClose(); };
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!name.trim()) { setError("نام پروژه الزامی است"); return; }
|
||||||
|
setError("");
|
||||||
|
onSubmit({ name: name.trim(), location: location.trim(), project_manager: projectManager.trim(), description: description.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={handleClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-2xl bg-panel shadow-2xl border border-line p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-base font-bold">افزودن پروژه جدید</h2>
|
||||||
|
<button type="button" onClick={handleClose} className="text-muted hover:text-on-surface transition-colors">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{error && <p className="rounded-lg bg-red-50 border border-red-200 text-red-700 px-3 py-2 text-xs">{error}</p>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">نام پروژه *</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
|
||||||
|
placeholder="مثلاً: برج مسکونی آسمان"
|
||||||
|
className={inputCls}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">محل پروژه</label>
|
||||||
|
<input value={location} onChange={(e) => setLocation(e.target.value)} placeholder="شهر / منطقه" className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">مدیر / سرپرست پروژه</label>
|
||||||
|
<input value={projectManager} onChange={(e) => setProjectManager(e.target.value)} placeholder="نام سرپرست" className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">توضیحات</label>
|
||||||
|
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="توضیحات تکمیلی پروژه..." className={`${inputCls} resize-none`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={handleClose} className="h-9 rounded-lg border border-line px-4 text-sm text-muted hover:bg-surface transition-colors">انصراف</button>
|
||||||
|
<button type="button" disabled={loading} onClick={handleSubmit} className="h-9 rounded-lg bg-accent px-4 text-sm font-semibold text-white hover:bg-accent/90 disabled:opacity-50 transition-colors">
|
||||||
|
{loading ? "در حال ذخیره..." : "افزودن پروژه"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
@@ -82,54 +165,53 @@ export function ProjectsPage() {
|
|||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const { standardConversions } = useUnits();
|
const { standardConversions } = useUnits();
|
||||||
const addProject = useAddProject();
|
const addProject = useAddProject();
|
||||||
|
const deleteProject = useDeleteProject();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
||||||
|
|
||||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||||
const [newName, setNewName] = useState("");
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
|
||||||
const addProj = () => {
|
const handleAddProject = async (data: Omit<Project, "id">) => {
|
||||||
const name = newName.trim();
|
const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
|
||||||
if (!name) return;
|
if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
|
||||||
const dup = projects.some((p) => normalize(p.name) === normalize(name));
|
try {
|
||||||
if (dup) {
|
await addProject.mutateAsync({ id: "", ...data });
|
||||||
toast.error("این پروژه قبلاً ثبت شده است.");
|
setShowAddModal(false);
|
||||||
return;
|
setOpenProject(data.name);
|
||||||
|
toast.success("پروژه با موفقیت اضافه شد.");
|
||||||
|
} catch {
|
||||||
|
toast.error("خطا در ثبت پروژه");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async (p: Project, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm(`آیا از حذف پروژه «${p.name}» مطمئن هستید؟`)) return;
|
||||||
|
try {
|
||||||
|
await deleteProject.mutateAsync(p.id);
|
||||||
|
toast.success("پروژه حذف شد.");
|
||||||
|
if (openProject === p.name) setOpenProject(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || "این پروژه دارای پیشنهاد است و قابل حذف نیست");
|
||||||
}
|
}
|
||||||
const p: Project = { id: nextId("p", projects), name };
|
|
||||||
void addProject.mutateAsync(p);
|
|
||||||
setNewName("");
|
|
||||||
setOpenProject(name);
|
|
||||||
toast.success("پروژه افزوده شد.");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="پروژهها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
|
<Header title="پروژهها" sub="مدیریت پروژههای مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
<div className="add-panel">
|
<div className="add-panel flex items-center justify-between">
|
||||||
<h4>+ افزودن پروژهی جدید</h4>
|
<h4>پروژههای سامانه</h4>
|
||||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
{canManage && (
|
||||||
<div className="field">
|
<Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
|
||||||
<label>نام پروژه *</label>
|
<Plus size={15} />
|
||||||
<input
|
افزودن پروژه
|
||||||
type="text"
|
</Button>
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
)}
|
||||||
placeholder="مثلاً برج آیتا - فاز ۳"
|
|
||||||
autoComplete="off"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addProj();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addProj}>افزودن پروژه</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!projects.length && !quotes.length ? (
|
{!projects.length && !quotes.length ? (
|
||||||
@@ -141,16 +223,37 @@ export function ProjectsPage() {
|
|||||||
const open = openProject === p.name;
|
const open = openProject === p.name;
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex flex-col gap-1.5">
|
<div key={p.id} className="flex flex-col gap-1.5">
|
||||||
|
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`proj-item ${open ? "open" : ""}`}
|
className="flex flex-1 items-center gap-2 min-w-0 text-right"
|
||||||
onClick={() => setOpenProject(open ? null : p.name)}
|
onClick={() => setOpenProject(open ? null : p.name)}
|
||||||
>
|
>
|
||||||
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
||||||
<span className="font-medium">{p.name}</span>
|
<span className="font-medium truncate">{p.name}</span>
|
||||||
<span className="proj-count">{faNum(count)} پیشنهاد</span>
|
{p.location && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
|
||||||
|
<MapPin size={10} />{p.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.project_manager && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
|
||||||
|
<UserCircle2 size={10} />{p.project_manager}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="proj-count ms-auto shrink-0">{faNum(count)} پیشنهاد</span>
|
||||||
</button>
|
</button>
|
||||||
{/* جزئیات دقیقاً زیر همان پروژه باز میشود */}
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDeleteProject(p, e)}
|
||||||
|
title="حذف پروژه"
|
||||||
|
className="text-muted hover:text-red-600 transition-colors p-1 rounded shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
|
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -158,6 +261,13 @@ export function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
|
<AddProjectModal
|
||||||
|
open={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSubmit={handleAddProject}
|
||||||
|
loading={addProject.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,7 +276,6 @@ export function ProjectsPage() {
|
|||||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||||
const quotes = projectQuotes(store.quotes, name);
|
const quotes = projectQuotes(store.quotes, name);
|
||||||
|
|
||||||
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
|
||||||
const itemsTotal = (q: Quote) =>
|
const itemsTotal = (q: Quote) =>
|
||||||
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||||
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||||
@@ -196,10 +305,6 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// دستهبندی ایزوله per-project:
|
|
||||||
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
|
||||||
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
|
||||||
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
|
||||||
const offered = quotes
|
const offered = quotes
|
||||||
.filter(
|
.filter(
|
||||||
(q) =>
|
(q) =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
||||||
import { materialLabel } from "../../lib/quoteUtils";
|
import { materialLabel } from "../../lib/quoteUtils";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
@@ -26,6 +27,8 @@ export function CategoryPage() {
|
|||||||
const addSupplier = useAddSupplier();
|
const addSupplier = useAddSupplier();
|
||||||
const removeSupplier = useRemoveSupplier();
|
const removeSupplier = useRemoveSupplier();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
|
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
|
||||||
|
|
||||||
@@ -121,7 +124,7 @@ export function CategoryPage() {
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
s={s}
|
s={s}
|
||||||
topic={cat}
|
topic={cat}
|
||||||
deletable
|
deletable={canManage}
|
||||||
onDelete={removeSup}
|
onDelete={removeSup}
|
||||||
materials={supMats.get(s.id) || []}
|
materials={supMats.get(s.id) || []}
|
||||||
avgLeadTime={supLeadTimes.get(s.id)}
|
avgLeadTime={supLeadTimes.get(s.id)}
|
||||||
@@ -130,6 +133,7 @@ export function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
<div className="add-panel">
|
<div className="add-panel">
|
||||||
<h4>
|
<h4>
|
||||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||||
@@ -182,6 +186,7 @@ export function CategoryPage() {
|
|||||||
ذخیره تأمینکننده
|
ذخیره تأمینکننده
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
|
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { topicByName } from "../../lib/topics";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { daysBetween } from "../../lib/formatters";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
@@ -13,6 +14,16 @@ import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
|||||||
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { PurchaseHistory } from "./PurchaseHistory";
|
import { PurchaseHistory } from "./PurchaseHistory";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||||
|
function cleanPhoneForTel(phone?: string | null): string {
|
||||||
|
if (!phone) return "";
|
||||||
|
const enDigits = phone
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
|
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||||||
export function SupplierDetailPage() {
|
export function SupplierDetailPage() {
|
||||||
@@ -22,6 +33,8 @@ export function SupplierDetailPage() {
|
|||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
|
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
|
||||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||||
@@ -29,6 +42,19 @@ export function SupplierDetailPage() {
|
|||||||
const [linked, setLinked] = useState<string[]>([]);
|
const [linked, setLinked] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// محاسبه میانگین زمان تحویل این تأمینکننده بر اساس خریدهای تحویلشده
|
||||||
|
const avgLeadTime = useMemo(() => {
|
||||||
|
if (!s?.id) return null;
|
||||||
|
const supQuotes = quotes.filter(
|
||||||
|
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||||||
|
);
|
||||||
|
const times = supQuotes
|
||||||
|
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||||
|
.filter((x): x is number => x != null);
|
||||||
|
|
||||||
|
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||||
|
}, [quotes, s?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -68,7 +94,6 @@ export function SupplierDetailPage() {
|
|||||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||||
|
|
||||||
const saveCategories = (names: string[]) => {
|
const saveCategories = (names: string[]) => {
|
||||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
|
||||||
if (!id || busy) return;
|
if (!id || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -116,22 +141,53 @@ export function SupplierDetailPage() {
|
|||||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-xl font-extrabold">{s.name}</div>
|
<div className="text-xl font-extrabold">{s.name}</div>
|
||||||
<Badge variant="outline">{topicName}</Badge>
|
<Badge variant="outline">{topicName}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
{avgLeadTime != null && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<span>{avgLeadTime} روز</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
|
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
|
||||||
</span>
|
<div className="flex items-center">
|
||||||
|
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
|
||||||
|
<b className="me-1">تلفن:</b>
|
||||||
|
{s.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||||
|
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
|
||||||
|
dir="ltr"
|
||||||
|
title="تماس مستقیم با تأمینکننده"
|
||||||
|
>
|
||||||
|
<span className="tabular-nums">{s.phone}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
|
||||||
|
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
{canManage && (
|
||||||
<Link
|
<Link
|
||||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||||||
@@ -139,6 +195,7 @@ export function SupplierDetailPage() {
|
|||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
پیشنهاد جدید
|
پیشنهاد جدید
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
{firstCat ? (
|
{firstCat ? (
|
||||||
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
||||||
‹ بازگشت به دسته
|
‹ بازگشت به دسته
|
||||||
@@ -151,7 +208,7 @@ export function SupplierDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
{/* متریالهای این تأمینکننده */}
|
||||||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<h4 className="mb-1 text-[13.5px] font-bold">
|
<h4 className="mb-1 text-[13.5px] font-bold">
|
||||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||||
@@ -164,19 +221,23 @@ export function SupplierDetailPage() {
|
|||||||
{linked.map((c) => (
|
{linked.map((c) => (
|
||||||
<span key={c} className="spec-chip">
|
<span key={c} className="spec-chip">
|
||||||
{c}
|
{c}
|
||||||
|
{canManage && (
|
||||||
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canManage && (
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={[]}
|
values={[]}
|
||||||
onChange={(cats) => saveCategories(cats)}
|
onChange={(cats) => saveCategories(cats)}
|
||||||
options={materialOptions}
|
options={materialOptions}
|
||||||
placeholder="افزودن متریال..."
|
placeholder="افزودن متریال..."
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{purchased.length > 0 && (
|
{purchased.length > 0 && (
|
||||||
<div className="mt-3 border-t border-line pt-2">
|
<div className="mt-3 border-t border-line pt-2">
|
||||||
<div className="mb-1 text-[11px] font-semibold text-muted">متریالهای خریداریشده (از پیشنهادها):</div>
|
<div className="mb-1 text-[11px] font-semibold text-muted">متریالهای خریداریشده (از پیشنهادها):</div>
|
||||||
@@ -191,7 +252,7 @@ export function SupplierDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
{/* سابقه خرید از این تأمینکننده */}
|
||||||
<div className="mt-4 max-w-4xl">
|
<div className="mt-4 max-w-4xl">
|
||||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||||
|
function cleanPhoneForTel(phone?: string | null): string {
|
||||||
|
if (!phone) return "";
|
||||||
|
const enDigits = phone
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
|
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||||
return suppliers.filter(
|
return suppliers.filter(
|
||||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||||
@@ -29,7 +38,11 @@ export function SupplierCard({
|
|||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head flex items-start justify-between gap-2">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
{/* فقط متن نام تأمینکننده لینک است و به کل کارت گسترش پیدا نمیکند */}
|
||||||
|
<Link
|
||||||
|
to={`/suppliers/${s.id}`}
|
||||||
|
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
|
||||||
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
@@ -49,17 +62,34 @@ export function SupplierCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||||
|
{s.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||||
|
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
|
||||||
|
title="تماس مستقیم با تأمینکننده"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
|
||||||
|
<span className="tabular-nums">{s.phone}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<span>
|
<span>
|
||||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{materials.length > 0 && (
|
{materials.length > 0 && (
|
||||||
<div className="mt-2 border-t border-line pt-1.5">
|
<div className="mt-2 border-t border-line pt-1.5">
|
||||||
<div className="mb-1 flex flex-wrap gap-1">
|
<div className="mb-1 flex flex-wrap gap-1">
|
||||||
@@ -71,6 +101,7 @@ export function SupplierCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sc-actions">
|
<div className="sc-actions">
|
||||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export interface ItemDraft {
|
export interface ItemDraft {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
@@ -51,7 +53,6 @@ export function materialOptions(
|
|||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
// متریالها سراسری هستند — مستقل از تاپیک
|
|
||||||
let list = materials;
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
@@ -67,21 +68,17 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و متریال
|
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
|
||||||
// همنام پیشنهاد میشوند.
|
|
||||||
if (cat)
|
if (cat)
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||||
);
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
|
||||||
const base = new Set<string>();
|
const base = new Set<string>();
|
||||||
apiSpecs.forEach((s) => s && base.add(s));
|
apiSpecs.forEach((s) => s && base.add(s));
|
||||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||||
@@ -90,7 +87,7 @@ export function materialOptions(
|
|||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
// مرحله ۲ — مدیریت اقلام سفارش
|
||||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
|
|
||||||
@@ -167,46 +164,83 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||||
}
|
}
|
||||||
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
|
||||||
// همان هویتِ متریال انجام میشود، نه دسته.
|
|
||||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
{/* هدر */}
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||||
|
۲
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||||
اقلام سفارش
|
اقلام سفارش
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
</div>
|
||||||
|
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
{faNum(items.length)} قلم
|
||||||
<span>متریال</span>
|
</span>
|
||||||
<span>برند</span>
|
</div>
|
||||||
<span>مشخصه</span>
|
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||||
<span>تعداد</span>
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||||
<span>واحد</span>
|
</p>
|
||||||
<span>قیمت واحد</span>
|
|
||||||
<span>قیمت کل</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-field="line_items" className="flex flex-col gap-2">
|
{/* لیست کارتهای اقلام */}
|
||||||
{items.map((it) => (
|
<div data-field="line_items" className="flex flex-col gap-3">
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
{items.map((it, idx) => (
|
||||||
|
<div
|
||||||
|
key={it.tempId}
|
||||||
|
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||||
|
>
|
||||||
|
{/* سربرگ قلم */}
|
||||||
|
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
||||||
|
{faNum(idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-ink truncate">
|
||||||
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => remove(it.tempId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">حذف</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۱: متریال و برند */}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.cat}
|
value={it.cat}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
items={allMaterialNames}
|
items={allMaterialNames}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان متریال جدید"
|
addSuffix="به عنوان متریال جدید"
|
||||||
placeholder="متریال"
|
placeholder="نام متریال"
|
||||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||||
onBlur={() => rememberField("i-cat", it)}
|
onBlur={() => rememberField("i-cat", it)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.brand}
|
value={it.brand}
|
||||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||||
@@ -216,34 +250,52 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||||
onBlur={() => rememberField("i-brand", it)}
|
onBlur={() => rememberField("i-brand", it)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۲: مشخصه فنی */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={it.specs}
|
values={it.specs}
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||||
placeholder="مشخصه"
|
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
|
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||||
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.qty}
|
value={it.qty}
|
||||||
onChange={(qty) => {
|
onChange={(qty) => {
|
||||||
patch(it.tempId, {
|
patch(it.tempId, {
|
||||||
qty,
|
qty,
|
||||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="تعداد"
|
placeholder="مقدار"
|
||||||
showWords
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.unit}
|
value={it.unit}
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان واحد جدید"
|
addSuffix="به عنوان واحد جدید"
|
||||||
placeholder="واحد"
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
onAdd={(v) => {
|
onAdd={(v) => {
|
||||||
const name = v.trim();
|
const name = v.trim();
|
||||||
@@ -257,56 +309,60 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}}
|
}}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۴: قیمت هر واحد (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||||
|
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.price}
|
value={it.price}
|
||||||
onChange={(price) => {
|
onChange={(price) => {
|
||||||
patch(it.tempId, {
|
patch(it.tempId, {
|
||||||
price,
|
price,
|
||||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت واحد"
|
placeholder="قیمت هر واحد"
|
||||||
showWords
|
showWords
|
||||||
maxDecimals={2}
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۵: قیمت کل قلم (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.total}
|
value={it.total}
|
||||||
onChange={(total) => {
|
onChange={(total) => {
|
||||||
patch(it.tempId, {
|
patch(it.tempId, {
|
||||||
total,
|
total,
|
||||||
price:
|
price:
|
||||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت کل"
|
placeholder="قیمت کل قلم"
|
||||||
showWords
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => remove(it.tempId)}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
{/* دکمه افزودن قلم جدید */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
||||||
onClick={add}
|
onClick={add}
|
||||||
>
|
>
|
||||||
<span className="text-base font-bold leading-none">+</span>
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن قلم جدید</span>
|
<span>افزودن قلم جدید</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,40 +9,58 @@ export interface StepperProps {
|
|||||||
onGoto: (step: number) => void;
|
onGoto: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
const STEPS = [
|
||||||
|
{ id: 1, label: "تأمینکننده" },
|
||||||
|
{ id: 2, label: "اقلام" },
|
||||||
|
{ id: 3, label: "شرایط پرداخت" },
|
||||||
|
{ id: 4, label: "پروژه" },
|
||||||
|
{ id: 5, label: "بازبینی و ثبت" },
|
||||||
|
];
|
||||||
|
|
||||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
// نوار ۵ مرحلهای ویزارد — واکنشگرا و ضدشکستگی در موبایل
|
||||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
|
||||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||||
{STEPS.map((label, i) => {
|
{STEPS.map(({ id: step, label }) => {
|
||||||
const step = i + 1;
|
|
||||||
const isCurrent = step === current;
|
const isCurrent = step === current;
|
||||||
const isComplete = completedSteps.has(step);
|
const isComplete = completedSteps.has(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={step}
|
key={step}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||||
|
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-[#2563eb] font-bold text-white"
|
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||||
: isComplete
|
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||||
? "bg-green-bg font-medium text-green"
|
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||||
: "hover:bg-slate-bg",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onGoto(step)}
|
onClick={() => onGoto(step)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
isCurrent
|
||||||
|
? "bg-white/20 text-white"
|
||||||
|
: isComplete
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||||
|
: "bg-muted/15 text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||||
|
!isCurrent && "hidden md:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -127,11 +127,9 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (_rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها ----------
|
||||||
const projectNames = useMemo(() => {
|
const projectNames = useMemo(() => {
|
||||||
const names = new Set<string>(projects.map((p) => p.name));
|
const names = new Set<string>(projects.map((p) => p.name));
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
@@ -159,7 +157,7 @@ export function WizardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- خلاصهی مرحله ۵ ----------
|
// ---------- خلاصه مرحله ۵ ----------
|
||||||
const review = useMemo(() => {
|
const review = useMemo(() => {
|
||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
@@ -202,23 +200,14 @@ export function WizardPage() {
|
|||||||
|
|
||||||
const combined = `${field} ${msg}`.toLowerCase();
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
// ۱. تأمینکننده
|
|
||||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
|
||||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
return { step: 2, field: field || "line_items" };
|
return { step: 2, field: field || "line_items" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
|
||||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
return { step: 3, field: field || "termsheets" };
|
return { step: 3, field: field || "termsheets" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۴. پروژه
|
|
||||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
|
||||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
@@ -291,10 +280,6 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
|
||||||
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
|
||||||
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
|
||||||
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
|
||||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
const matched = matchMaterial(
|
const matched = matchMaterial(
|
||||||
@@ -426,7 +411,7 @@ export function WizardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-10">
|
||||||
<Header
|
<Header
|
||||||
title="افزودن پیشنهاد جدید"
|
title="افزودن پیشنهاد جدید"
|
||||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
@@ -434,10 +419,12 @@ export function WizardPage() {
|
|||||||
backLabel="بازگشت به دفتر"
|
backLabel="بازگشت به دفتر"
|
||||||
/>
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
|
{/* نوار مراحل استپر */}
|
||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
|
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -461,12 +448,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -477,7 +464,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -491,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
@@ -506,15 +493,14 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
{/* پاورقی کنترل مراحل */}
|
||||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
|
||||||
<div>
|
<div>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToStep(currentStep - 1)}
|
onClick={() => goToStep(currentStep - 1)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<span>قبلی</span>
|
<span>قبلی</span>
|
||||||
@@ -522,21 +508,19 @@ export function WizardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
|
||||||
>
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>بعدی</span>
|
<span>بعدی</span>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -547,7 +531,7 @@ export function WizardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>ذخیره پیشنهاد</span>
|
<span>ذخیره پیشنهاد</span>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -556,21 +540,21 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
{/* دیالوگ فیلدهای خالی */}
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-[13.5px] leading-7">
|
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||||
{emptyFields.map((f) => (
|
{emptyFields.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.step}
|
key={f.step}
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
goToStep(f.step);
|
goToStep(f.step);
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { persist } from "zustand/middleware";
|
|||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
project_id: string | null; // پروژهی فعال (PM) — با سوییچر عوض میشود
|
project_id: string | null; // پروژهی فعال (PM) — با سوییچر عوض میشود
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface AppStore {
|
|||||||
deleteSupplier: (id: string) => void;
|
deleteSupplier: (id: string) => void;
|
||||||
addMaterial: (m: Material) => void;
|
addMaterial: (m: Material) => void;
|
||||||
addProject: (p: Project) => void;
|
addProject: (p: Project) => void;
|
||||||
|
deleteProject: (id: string) => void;
|
||||||
setStandardConversion: (sc: StandardConversion) => void;
|
setStandardConversion: (sc: StandardConversion) => void;
|
||||||
deleteStandardConversion: (id: string) => void;
|
deleteStandardConversion: (id: string) => void;
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export const useAppStore = create<AppStore>()((set) => ({
|
|||||||
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
||||||
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
||||||
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
||||||
|
deleteProject: (id) => set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })),
|
||||||
setStandardConversion: (sc) =>
|
setStandardConversion: (sc) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
||||||
|
|||||||
@@ -625,4 +625,15 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
details JSONB NOT NULL DEFAULT '{}'
|
details JSONB NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ---------- UNIFIED PROJECTS (PM & PROCUREMENT SHARED SCHEMA) ----------
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT '';
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT '';
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE';
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
Reference in New Issue
Block a user