diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 018ff34..789872a 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -174,7 +174,7 @@ _V26_DDL = [ """, """ DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); + CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ """, """ diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index f41b355..3860c87 100644 --- a/backend/app/models/procurement.py +++ b/backend/app/models/procurement.py @@ -26,7 +26,7 @@ from app.db.base import Base from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk user_role_enum = Enum( - "pm", "procurement_manager", "admin", + "pm", "procurement_manager", "admin", "supervisor", name="user_role", native_enum=True, ) quote_status_enum = Enum( diff --git a/backend/app/routers/attribute_definitions.py b/backend/app/routers/attribute_definitions.py index 8e0c0ad..6124262 100644 --- a/backend/app/routers/attribute_definitions.py +++ b/backend/app/routers/attribute_definitions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.procurement import LineItemSpec @@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) +_procurement_writer = require_role("admin", "procurement_manager") def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: @@ -69,7 +71,11 @@ async def list_attribute_definitions( @router.post("", status_code=201) -async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)): +async def create_attribute_definition( + payload: AttributeDefinitionCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key)) if duplicate is not None: raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") @@ -102,7 +108,10 @@ async def get_attribute_definition( @router.patch("/{definition_id}") async def update_attribute_definition( - definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db) + definition_id, + payload: AttributeDefinitionUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ad = await db.get(AttributeDefinition, definition_id) if ad is None: @@ -136,7 +145,11 @@ async def update_attribute_definition( @router.delete("/{definition_id}") -async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)): +async def delete_attribute_definition( + definition_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): ad = await db.get(AttributeDefinition, definition_id) if ad is None: raise NotFoundError("تعریف ویژگی یافت نشد") diff --git a/backend/app/routers/brand_materials.py b/backend/app/routers/brand_materials.py index 1200f96..13f96d8 100644 --- a/backend/app/routers/brand_materials.py +++ b/backend/app/routers/brand_materials.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial, Material from app.models.procurement import LineItem @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: @@ -70,7 +72,11 @@ async def list_brand_materials( @router.post("", status_code=201) -async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)): +async def create_brand_material( + payload: BrandMaterialCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): if await db.get(Brand, payload.brand_id) is None: raise NotFoundError("برند یافت نشد", field="brand_id") if await db.get(Material, payload.material_id) is None: @@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession @router.patch("/{bm_id}") -async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)): +async def update_brand_material( + bm_id, + payload: BrandMaterialUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") @@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe @router.delete("/{bm_id}") -async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)): +async def delete_brand_material( + bm_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py index 9173c48..1112cc0 100644 --- a/backend/app/routers/brands.py +++ b/backend/app/routers/brands.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial from app.schemas.catalog import BrandCreate, BrandUpdate @@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brands", tags=["brands"]) +_procurement_writer = require_role("admin", "procurement_manager") def brand_to_dict(brand: Brand) -> dict: @@ -42,7 +44,11 @@ async def list_brands( @router.post("", status_code=201) -async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)): +async def create_brand( + payload: BrandCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all() if any(b.country == payload.country for b in rows): raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") @@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)): @router.patch("/{brand_id}") -async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)): +async def update_brand( + brand_id, + payload: BrandUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") @@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend @router.delete("/{brand_id}") -async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)): +async def delete_brand( + brand_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 791dd16..ea5b4ac 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token router = APIRouter(prefix="/quotes", tags=["files"]) -# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود +# حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود # برای هر کاربر واردشده آزاد است. _attachment_deleter = require_role("procurement_manager", "admin") +_attachment_writer = require_role("procurement_manager", "admin") ALLOWED_MIME_TYPES = { "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", @@ -125,7 +126,7 @@ async def _attachment_or_404( # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # --------------------------------------------------------------------------- -@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) +@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)]) async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) @@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn # Proforma — no status restriction # --------------------------------------------------------------------------- -@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) +@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)]) async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) diff --git a/backend/app/routers/item_receipts.py b/backend/app/routers/item_receipts.py index f29254e..12d2d6d 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.schemas.procurement import ReceiptCreate, ReceiptUpdate @@ -14,6 +15,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", tags=["item-receipts"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db) @router.post("", status_code=201) -async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)): +async def create_receipt( + quote_id, + line_item_id, + payload: ReceiptCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): await _line_item_or_404(db, quote_id, line_item_id) decision = await _decision_or_404(db, line_item_id) existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) @@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy @router.patch("") -async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)): +async def update_receipt( + quote_id, + line_item_id, + payload: ReceiptUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): await _line_item_or_404(db, quote_id, line_item_id) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) if decision is None: diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py index 2d2977c..701e25c 100644 --- a/backend/app/routers/item_termsheets.py +++ b/backend/app/routers/item_termsheets.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -23,6 +24,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", tags=["item-termsheets"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend @router.post("", status_code=201) async def create_item_termsheet( - quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + payload: ItemTermsheetCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) @@ -93,7 +99,12 @@ async def create_item_termsheet( @router.patch("/{item_termsheet_id}") async def update_item_termsheet( - quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + item_termsheet_id, + payload: ItemTermsheetUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await db.get(Quote, quote_id) @@ -114,7 +125,11 @@ async def update_item_termsheet( @router.delete("/{item_termsheet_id}") async def delete_item_termsheet( - quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + item_termsheet_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): it = await db.scalar( select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py index 9f49e95..a00f535 100644 --- a/backend/app/routers/line_item_specs.py +++ b/backend/app/routers/line_item_specs.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Unit from app.models.procurement import LineItem, LineItemSpec, Quote @@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values from app.utils.errors import ConflictError, NotFoundError router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends @router.put("") -async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)): +async def put_line_item_specs( + quote_id, + line_item_id, + payload: SpecsPutIn, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): """FULL replacement — specs not in the payload are DELETED (P1-19).""" async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py index 963d1f5..e9f6c79 100644 --- a/backend/app/routers/line_items.py +++ b/backend/app/routers/line_items.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material, Unit from app.models.procurement import ItemDecision, LineItem, Quote @@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: @@ -50,7 +52,12 @@ async def list_line_items( @router.post("", status_code=201) -async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): +async def create_line_item( + quote_id, + payload: LineItemCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -89,7 +96,11 @@ async def get_line_item( @router.patch("/{line_item_id}") async def update_line_item( - quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + payload: LineItemUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) @@ -131,7 +142,12 @@ async def update_line_item( @router.delete("/{line_item_id}") -async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): +async def delete_line_item( + quote_id, + line_item_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py index 8611a74..ccd4ce5 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory 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 router = APIRouter(prefix="/material-categories", tags=["material-categories"]) +_procurement_writer = require_role("admin", "procurement_manager") def category_to_dict(category: MaterialCategory) -> dict: @@ -46,7 +48,11 @@ async def list_categories( @router.post("", status_code=201) -async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)): +async def create_category( + payload: MaterialCategoryCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) if duplicate is not None: raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") @@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)): @router.patch("/{category_id}") -async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)): +async def update_category( + category_id, + payload: MaterialCategoryUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") @@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn @router.delete("/{category_id}") -async def delete_category(category_id, db: AsyncSession = Depends(get_db)): +async def delete_category( + category_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index c128f9c..c78fcdd 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import ( AttributeDefinition, @@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/materials", tags=["materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def _coerce_filter_value(data_type: str, raw: str): @@ -125,7 +127,10 @@ async def list_materials( @router.post("", status_code=201) async def create_material( - payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db) + payload: MaterialCreate, + expand: str | None = None, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): material = await material_service.create_material(db, payload) return {"data": await serialize_material(db, material, parse_expand(expand))} @@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession @router.patch("/{material_id}") -async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)): +async def update_material( + material_id, + payload: MaterialUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession @router.delete("/{material_id}") -async def delete_material(material_id, db: AsyncSession = Depends(get_db)): +async def delete_material( + material_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -248,7 +262,10 @@ async def list_material_units( @router.post("/{material_id}/units", status_code=201) async def create_material_unit( - material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db) + material_id, + payload: MaterialUnitCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): await _material_or_404(db, material_id) @@ -287,7 +304,11 @@ async def create_material_unit( @router.patch("/{material_id}/units/{mu_id}") async def update_material_unit( - material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db) + material_id, + mu_id, + payload: MaterialUnitUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): mu = await db.scalar( @@ -319,7 +340,12 @@ async def update_material_unit( @router.delete("/{material_id}/units/{mu_id}") -async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)): +async def delete_material_unit( + material_id, + mu_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): mu = await db.scalar( select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) ) @@ -365,7 +391,10 @@ async def get_material_attribute_values( @router.put("/{material_id}/attribute-values") async def put_material_attribute_values( - material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db) + material_id, + payload: MaterialAttributeValuesPut, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): """FULL replacement of a material's EAV values (empty array deletes all).""" async with db.begin(): diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 2ebd785..abf791a 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import UserContext, get_optional_user +from app.auth import UserContext, get_optional_user, require_role from app.db.engine import get_db from app.models.procurement import Project, Quote, User from app.schemas.procurement import ProjectCreate, ProjectUpdate @@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_date, to_iso router = APIRouter(prefix="/projects", tags=["projects"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: @@ -77,7 +78,11 @@ async def list_projects( @router.post("", status_code=201) -async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)): +async def create_project( + payload: ProjectCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): project = Project( name=payload.name, description=payload.description, @@ -103,6 +108,7 @@ async def update_project( request: Request, ctx: UserContext | None = Depends(_optional_ctx), db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): project = await db.get(Project, project_id) if project is None: @@ -133,7 +139,11 @@ async def update_project( @router.delete("/{project_id}") -async def delete_project(project_id, db: AsyncSession = Depends(get_db)): +async def delete_project( + project_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py index 1f91c41..962bb0b 100644 --- a/backend/app/routers/quote_events.py +++ b/backend/app/routers/quote_events.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import Quote, QuoteEvent from app.schemas.procurement import NoteEventIn @@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) +_procurement_writer = require_role("admin", "procurement_manager") @router.get("") @@ -34,7 +36,12 @@ async def list_events( @router.post("", status_code=201) -async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)): +async def create_event( + quote_id, + payload: NoteEventIn, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): quote = await db.get(Quote, quote_id) if quote is None: raise NotFoundError("پیشنهاد (استعلام) یافت نشد") diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index 489921c..2849be3 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material from app.models.procurement import ( @@ -42,6 +43,9 @@ from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) +# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) +_procurement_writer = require_role("admin", "procurement_manager") + # --- 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) -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) 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}") async def update_quote( - quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: QuoteUpdate, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.update_quote(db, quote_id, payload, actor) return {"data": await serialize_quote(db, quote, set())} @@ -175,7 +188,11 @@ async def update_quote( @router.patch("/{quote_id}/children") async def replace_quote_children( - quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: QuoteChildrenReplaceIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): """Full replacement of a quote's line items + termsheets (edit modal).""" quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) @@ -188,7 +205,11 @@ async def replace_quote_children( @router.patch("/{quote_id}/accept") async def accept_quote( - quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: AcceptQuoteIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.accept_quote(db, quote_id, payload, actor) return {"data": await _accept_response(db, quote)} @@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict: @router.patch("/{quote_id}/arrive") -async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def arrive_quote( + quote_id, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.arrive_quote(db, quote_id, actor) return { "data": { @@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str @router.patch("/{quote_id}/arrive-items") async def arrive_items( - quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: ArriveItemsIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.arrive_items(db, quote_id, payload, actor) return { @@ -259,14 +289,23 @@ async def arrive_items( @router.patch("/{quote_id}/reject") async def reject_quote( - quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: RejectQuoteIn | None = None, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) return {"data": {"id": str(quote.id), "status": quote.status}} @router.patch("/{quote_id}/expire") -async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def expire_quote( + quote_id, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.expire_quote(db, quote_id, actor) return {"data": {"id": str(quote.id), "status": quote.status}} @@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str # --------------------------------------------------------------------------- @router.delete("/{quote_id}") -async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)): +async def delete_quote( + quote_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): result = await quote_service.delete_quote(db, quote_id) return {"data": result} diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py index fc265e7..d2c03d4 100644 --- a/backend/app/routers/schedule_steps.py +++ b/backend/app/routers/schedule_steps.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import Termsheet, TermsheetScheduleStep from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate @@ -15,6 +16,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", tags=["schedule-steps"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: @@ -57,7 +59,11 @@ async def list_schedule_steps( @router.post("", status_code=201) async def create_schedule_step( - quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: ScheduleStepCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) step_no = payload.step_no @@ -84,7 +90,12 @@ async def create_schedule_step( @router.patch("/{step_id}") async def update_schedule_step( - quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + step_id, + payload: ScheduleStepUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): step = await _step_or_404(db, termsheet_id, step_id) if payload.step_no is not None and payload.step_no != step.step_no: @@ -109,7 +120,13 @@ async def update_schedule_step( @router.delete("/{step_id}") -async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)): +async def delete_schedule_step( + quote_id, + termsheet_id, + step_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): step = await _step_or_404(db, termsheet_id, step_id) count = await db.scalar( select(func.count()).select_from(TermsheetScheduleStep).where( diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py index b63434a..6469399 100644 --- a/backend/app/routers/standard_conversions.py +++ b/backend/app/routers/standard_conversions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import StandardConversion from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate @@ -11,6 +12,7 @@ from app.utils.pagination import paginate from app.utils.serialization import to_iso router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) +_procurement_writer = require_role("admin", "procurement_manager") def sc_to_dict(sc: StandardConversion) -> dict: @@ -41,7 +43,11 @@ async def list_standard_conversions( @router.post("", status_code=201) -async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)): +async def create_standard_conversion( + payload: StandardConversionCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar( select(StandardConversion.id).where( StandardConversion.from_unit == payload.from_unit, @@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): @router.patch("/{sc_id}") -async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)): +async def update_standard_conversion( + sc_id, + payload: StandardConversionUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") @@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d @router.delete("/{sc_id}") -async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): +async def delete_standard_conversion( + sc_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 26e4026..02d5a55 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material, MaterialCategory from app.models.procurement import Supplier, SupplierMaterial @@ -18,6 +19,7 @@ from app.utils.pagination import paginate from app.utils.serialization import to_iso router = APIRouter(tags=["supplier-materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def link_to_dict(link: SupplierMaterial, material: Material | None = None, @@ -63,7 +65,10 @@ async def list_supplier_materials( @router.post("/suppliers/{supplier_id}/materials", status_code=201) async def create_supplier_material( - supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) + supplier_id, + payload: SupplierMaterialCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): link = await link_supplier_material(db, supplier_id, payload.material_id) @@ -72,7 +77,12 @@ async def create_supplier_material( @router.delete("/suppliers/{supplier_id}/materials/{material_id}") -async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)): +async def delete_supplier_material( + supplier_id, + material_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): await unlink_supplier_material(db, supplier_id, material_id) return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py index 3ab5175..070a9e6 100644 --- a/backend/app/routers/suppliers.py +++ b/backend/app/routers/suppliers.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Quote, Supplier @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/suppliers", tags=["suppliers"]) +_procurement_writer = require_role("admin", "procurement_manager") def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: @@ -71,7 +73,11 @@ async def list_suppliers( @router.post("", status_code=201) -async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)): +async def create_supplier( + payload: SupplierCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await _category_or_404(db, payload.category_id) supplier = Supplier( name=payload.name, @@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)): @router.patch("/{supplier_id}") -async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)): +async def update_supplier( + supplier_id, + payload: SupplierUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") @@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession @router.delete("/{supplier_id}") -async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)): +async def delete_supplier( + supplier_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py index 16c8f98..b1d5ffd 100644 --- a/backend/app/routers/termsheets.py +++ b/backend/app/routers/termsheets.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: @@ -80,7 +82,12 @@ async def list_termsheets( @router.post("", status_code=201) -async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)): +async def create_termsheet( + quote_id, + payload: TermsheetCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -110,7 +117,11 @@ async def get_termsheet( @router.patch("/{termsheet_id}") async def update_termsheet( - quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: TermsheetUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) if ts.status != "offered": @@ -127,7 +138,11 @@ async def update_termsheet( @router.patch("/{termsheet_id}/status") async def update_termsheet_status( - quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: TermsheetStatusUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): ts = await _termsheet_or_404(db, quote_id, termsheet_id) @@ -139,7 +154,12 @@ async def update_termsheet_status( @router.delete("/{termsheet_id}") -async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)): +async def delete_termsheet( + quote_id, + termsheet_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): ts = await _termsheet_or_404(db, quote_id, termsheet_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py index 7152ecd..5004f7a 100644 --- a/backend/app/routers/units.py +++ b/backend/app/routers/units.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.procurement import LineItem, LineItemSpec @@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/units", tags=["units"]) +_procurement_writer = require_role("admin", "procurement_manager") def unit_full_to_dict(unit: Unit) -> dict: @@ -45,7 +47,11 @@ async def list_units( @router.post("", status_code=201) -async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)): +async def create_unit( + payload: UnitCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) if duplicate is not None: raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") @@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)): @router.patch("/{unit_id}") -async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)): +async def update_unit( + unit_id: int, + payload: UnitUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") @@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe @router.delete("/{unit_id}") -async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)): +async def delete_unit( + unit_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 143c125..666c669 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -203,7 +203,7 @@ class UserCreate(APIModel): last_name: str = "" phone: str | None = None casdoor_id: str | None = None - role: Literal["pm", "procurement_manager", "admin"] | None = None + role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None class UserRoleUpdate(APIModel): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93e3d4b..8794d12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,6 +71,15 @@ function HomeRoute() { return ; } +// دسترسی ایجاد استعلام/پیشنهاد (ویزارد) فقط برای مدیر تدارکات و ادمین +function RequireProcurementWrite({ children }: { children: React.ReactNode }) { + const user = useAuthStore((s) => s.user); + if (user?.role !== "admin" && user?.role !== "procurement_manager") { + return ; + } + return <>{children}; +} + // پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود و // اینجا به‌صورت iframe تمام‌صفحه داخل پوسته ERP نمایش داده می‌شود. function PmPanelPage() { @@ -109,7 +118,9 @@ export default function App() { path="/wizard" element={ - + + + } /> diff --git a/frontend/src/components/AppSwitcherMenu.tsx b/frontend/src/components/AppSwitcherMenu.tsx index 688bfcc..182bad8 100644 --- a/frontend/src/components/AppSwitcherMenu.tsx +++ b/frontend/src/components/AppSwitcherMenu.tsx @@ -17,7 +17,7 @@ export function AppSwitcherMenu({ canSwitchToProcurement = true, }: { activeModule: "warehouse" | "procurement"; - canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد + canSwitchToProcurement?: boolean; }) { const logout = useLogout(); const { pathname } = useLocation(); diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index 227b1cd..6ce0103 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AcceptModal } from "./AcceptModal"; import { EditModal } from "./EditModal"; +import { useAuthStore } from "../../store/auth"; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" }; export function DashboardPage() { useStoreData(); + const user = useAuthStore((s) => s.user); + const canWrite = user?.role === "admin" || user?.role === "procurement_manager"; + const quotes = useQuotes(); const suppliers = useSuppliers(); const materials = useMaterials(); @@ -114,13 +118,15 @@ export function DashboardPage() { {/* دکمه اصلی: افزودن پیشنهاد جدید */} - - - افزودن پیشنهاد جدید - + {canWrite && ( + + + افزودن پیشنهاد جدید + + )} } /> @@ -148,17 +154,20 @@ export function DashboardPage() { onToggleExpand={toggleExpand} onAccept={setAcceptId} onEdit={setEditId} + canWrite={canWrite} /> {/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */} - - - + {canWrite && ( + + + + )} setAcceptId(null)} /> setEditId(null)} /> diff --git a/frontend/src/pages/Dashboard/LedgerTable.tsx b/frontend/src/pages/Dashboard/LedgerTable.tsx index f4a55f5..37789ad 100644 --- a/frontend/src/pages/Dashboard/LedgerTable.tsx +++ b/frontend/src/pages/Dashboard/LedgerTable.tsx @@ -10,6 +10,7 @@ export interface LedgerTableProps { onToggleExpand: (id: string) => void; onAccept: (id: string) => void; onEdit: (id: string) => void; + canWrite?: boolean; } // دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل) @@ -20,6 +21,7 @@ export function LedgerTable({ onToggleExpand, onAccept, onEdit, + canWrite = true, }: LedgerTableProps) { if (!quotes.length) { return ( @@ -40,6 +42,7 @@ export function LedgerTable({ onToggleExpand={() => onToggleExpand(q.id)} onAccept={() => onAccept(q.id)} onEdit={() => onEdit(q.id)} + canWrite={canWrite} /> ))}
diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index 249ae5b..882d134 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -24,6 +24,7 @@ export interface QuoteCardProps { onToggleExpand: () => void; onAccept: () => void; onEdit: () => void; + canWrite?: boolean; } function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { @@ -35,7 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea ); } -export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { +export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) { const sup = supplierById(store.suppliers, q.supplier_id); const expired = isExpired(q); const total = quoteTotal(q); @@ -46,7 +47,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit // ستون / دکمه پذیرش let acceptedCell: React.ReactNode; if (q.status === "offered") { - acceptedCell = ( + acceptedCell = canWrite ? ( + ) : ( + در انتظار پذیرش ); } else if (q.accepted_date) { acceptedCell = {fmtDateTime(q.accepted_date)}; @@ -185,19 +188,21 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit )} {/* دکمه ویرایش */} - + {canWrite && ( + + )} {/* دکمه فلش اکسپند در هدر */}
- - + )} {!projects.length && !quotes.length ? ( diff --git a/frontend/src/pages/Suppliers/CategoryPage.tsx b/frontend/src/pages/Suppliers/CategoryPage.tsx index e22320e..a1d2f71 100644 --- a/frontend/src/pages/Suppliers/CategoryPage.tsx +++ b/frontend/src/pages/Suppliers/CategoryPage.tsx @@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox"; import { SupplierCard, suppliersOfCategory } from "./supplierUtils"; import { materialLabel } from "../../lib/quoteUtils"; import { Button } from "../../components/ui/button"; +import { useAuthStore } from "../../store/auth"; // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده export function CategoryPage() { @@ -26,6 +27,8 @@ export function CategoryPage() { const addSupplier = useAddSupplier(); const removeSupplier = useRemoveSupplier(); const topics = useAppStore((s) => s.topics); + const user = useAuthStore((s) => s.user); + const canManage = user?.role === "admin" || user?.role === "procurement_manager"; const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]); @@ -121,7 +124,7 @@ export function CategoryPage() { key={s.id} s={s} topic={cat} - deletable + deletable={canManage} onDelete={removeSup} materials={supMats.get(s.id) || []} avgLeadTime={supLeadTimes.get(s.id)} @@ -130,10 +133,11 @@ export function CategoryPage() { )} -
-

- افزودن تأمین‌کننده‌ی جدید -

+ {canManage && ( +
+

+ افزودن تأمین‌کننده‌ی جدید +

@@ -182,6 +186,7 @@ export function CategoryPage() { ذخیره تأمین‌کننده
+ )}
); diff --git a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx index 6fe0d83..45f679a 100644 --- a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx +++ b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx @@ -14,6 +14,7 @@ import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog"; import { normalize } from "../../lib/normalize"; import { PurchaseHistory } from "./PurchaseHistory"; +import { useAuthStore } from "../../store/auth"; // تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS) function cleanPhoneForTel(phone?: string | null): string { @@ -32,6 +33,8 @@ export function SupplierDetailPage() { const quotes = useQuotes(); const materials = useMaterials(); const topics = useAppStore((s) => s.topics); + const user = useAuthStore((s) => s.user); + const canManage = user?.role === "admin" || user?.role === "procurement_manager"; const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]); const [pendingRemove, setPendingRemove] = useState(null); @@ -184,13 +187,15 @@ export function SupplierDetailPage() {
- - - پیشنهاد جدید - + {canManage && ( + + + پیشنهاد جدید + + )} {firstCat ? ( ‹ بازگشت به دسته @@ -216,19 +221,23 @@ export function SupplierDetailPage() { {linked.map((c) => ( {c} - + {canManage && ( + + )} ))}
)} - saveCategories(cats)} - options={materialOptions} - placeholder="افزودن متریال..." - /> + {canManage && ( + saveCategories(cats)} + options={materialOptions} + placeholder="افزودن متریال..." + /> + )} {purchased.length > 0 && (
متریال‌های خریداری‌شده (از پیشنهادها):