Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f | ||
|
|
d69ec7f7a9 | ||
|
|
34290b9371 | ||
|
|
cff35e3660 | ||
|
|
b469238153 | ||
|
|
2fd72fecf7 | ||
|
|
ae62a0391b |
+16
-19
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_DASHBOARD_DDL = [
|
_DASHBOARD_DDL = [
|
||||||
# One reference unit per category (units.html «واحد مرجع هر دسته»)
|
# One reference unit per category (units.html «واحد مرجع هر دسته»)
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS category_reference_units (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
|
|
||||||
ref_unit TEXT NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
|
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
|
||||||
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
|
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS unit_conversions (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
|
||||||
brand TEXT,
|
|
||||||
from_unit TEXT NOT NULL,
|
|
||||||
to_unit TEXT NOT NULL,
|
|
||||||
factor NUMERIC(18,6) NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
|
||||||
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
|
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
|
||||||
""",
|
""",
|
||||||
@@ -413,6 +394,22 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||||||
|
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
|
||||||
|
|
||||||
|
These three tables backed features that were never populated (0 rows on every
|
||||||
|
volume): per-category reference units and conversion factors (the
|
||||||
|
per-reference-unit price line) and the category -> attribute mapping. Dropping
|
||||||
|
them is idempotent and loses no data. material_categories itself stays — every
|
||||||
|
supplier carries one as its label.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
|
||||||
|
))
|
||||||
|
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||||
ddl = """
|
ddl = """
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.db.engine import AsyncSessionLocal, engine
|
|||||||
from app.db.init_db import (
|
from app.db.init_db import (
|
||||||
ensure_attachment_table,
|
ensure_attachment_table,
|
||||||
ensure_audit_table,
|
ensure_audit_table,
|
||||||
|
ensure_drop_category_tables,
|
||||||
ensure_dashboard_tables,
|
ensure_dashboard_tables,
|
||||||
ensure_material_topic_column,
|
ensure_material_topic_column,
|
||||||
ensure_quote_projects,
|
ensure_quote_projects,
|
||||||
@@ -105,6 +106,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
await ensure_quote_projects(engine)
|
await ensure_quote_projects(engine)
|
||||||
await ensure_v26(engine)
|
await ensure_v26(engine)
|
||||||
await ensure_audit_table(engine)
|
await ensure_audit_table(engine)
|
||||||
|
await ensure_drop_category_tables(engine)
|
||||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ from app.routers import (
|
|||||||
auth,
|
auth,
|
||||||
brand_materials,
|
brand_materials,
|
||||||
brands,
|
brands,
|
||||||
category_attributes,
|
|
||||||
category_ref_units,
|
|
||||||
files,
|
files,
|
||||||
inventory,
|
inventory,
|
||||||
item_decisions,
|
item_decisions,
|
||||||
@@ -24,7 +22,6 @@ from app.routers import (
|
|||||||
supplier_materials,
|
supplier_materials,
|
||||||
suppliers,
|
suppliers,
|
||||||
termsheets,
|
termsheets,
|
||||||
unit_conversions,
|
|
||||||
units,
|
units,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
@@ -35,7 +32,6 @@ ROUTERS = [
|
|||||||
brands.router,
|
brands.router,
|
||||||
material_categories.router,
|
material_categories.router,
|
||||||
attribute_definitions.router,
|
attribute_definitions.router,
|
||||||
category_attributes.router,
|
|
||||||
materials.router,
|
materials.router,
|
||||||
brand_materials.router,
|
brand_materials.router,
|
||||||
suppliers.router,
|
suppliers.router,
|
||||||
@@ -51,8 +47,6 @@ ROUTERS = [
|
|||||||
item_receipts.router,
|
item_receipts.router,
|
||||||
quote_events.router,
|
quote_events.router,
|
||||||
files.router,
|
files.router,
|
||||||
category_ref_units.router,
|
|
||||||
unit_conversions.router,
|
|
||||||
standard_conversions.router,
|
standard_conversions.router,
|
||||||
inventory.router,
|
inventory.router,
|
||||||
pm.router,
|
pm.router,
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
"""Category attributes — scoped attribute definitions per category (P2 set).
|
|
||||||
|
|
||||||
Identified by composite key {category_id}/{attribute_definition_id}.
|
|
||||||
The nested `attribute` object is ALWAYS included (not expand-based).
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.engine import get_db
|
|
||||||
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
|
|
||||||
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
|
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
|
||||||
from app.utils.pagination import paginate
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/material-categories/{category_id}/attributes",
|
|
||||||
tags=["category-attributes"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
|
|
||||||
return {
|
|
||||||
"id": str(ca.id),
|
|
||||||
"category_id": str(ca.category_id),
|
|
||||||
"attribute_definition_id": str(ca.attribute_definition_id),
|
|
||||||
"is_required": ca.is_required,
|
|
||||||
"is_filterable": ca.is_filterable,
|
|
||||||
"conditional_rules": ca.conditional_rules,
|
|
||||||
"sort_order": ca.sort_order,
|
|
||||||
"attribute": {
|
|
||||||
"attribute_key": attribute.attribute_key,
|
|
||||||
"label": attribute.label,
|
|
||||||
"data_type": attribute.data_type,
|
|
||||||
"unit_id": attribute.unit_id,
|
|
||||||
"enum_values": attribute.enum_values,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
|
|
||||||
category = await db.get(MaterialCategory, category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
|
||||||
return category
|
|
||||||
|
|
||||||
|
|
||||||
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
|
|
||||||
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
|
|
||||||
return ca_to_dict(ca, attribute)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_category_attributes(
|
|
||||||
category_id,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
await _category_or_404(db, category_id)
|
|
||||||
stmt = (
|
|
||||||
select(CategoryAttribute)
|
|
||||||
.where(CategoryAttribute.category_id == category_id)
|
|
||||||
.order_by(CategoryAttribute.sort_order.asc())
|
|
||||||
)
|
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
|
||||||
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
|
||||||
async def create_category_attribute(
|
|
||||||
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
await _category_or_404(db, category_id)
|
|
||||||
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
|
|
||||||
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
|
|
||||||
duplicate = await db.scalar(
|
|
||||||
select(CategoryAttribute.id).where(
|
|
||||||
CategoryAttribute.category_id == category_id,
|
|
||||||
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if duplicate is not None:
|
|
||||||
raise ConflictError("این ویژگی قبلاً به این دستهبندی اضافه شده است", field="attribute_definition_id")
|
|
||||||
ca = CategoryAttribute(
|
|
||||||
category_id=category_id,
|
|
||||||
attribute_definition_id=payload.attribute_definition_id,
|
|
||||||
is_required=payload.is_required,
|
|
||||||
is_filterable=payload.is_filterable,
|
|
||||||
conditional_rules=payload.conditional_rules,
|
|
||||||
sort_order=payload.sort_order,
|
|
||||||
)
|
|
||||||
db.add(ca)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": await _with_attribute(db, ca)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{attribute_definition_id}")
|
|
||||||
async def update_category_attribute(
|
|
||||||
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
ca = await db.scalar(
|
|
||||||
select(CategoryAttribute).where(
|
|
||||||
CategoryAttribute.category_id == category_id,
|
|
||||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if ca is None:
|
|
||||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
|
||||||
if payload.is_required is not None:
|
|
||||||
ca.is_required = payload.is_required
|
|
||||||
if payload.is_filterable is not None:
|
|
||||||
ca.is_filterable = payload.is_filterable
|
|
||||||
if payload.conditional_rules is not None:
|
|
||||||
ca.conditional_rules = payload.conditional_rules
|
|
||||||
if payload.sort_order is not None:
|
|
||||||
ca.sort_order = payload.sort_order
|
|
||||||
await db.flush()
|
|
||||||
return {"data": await _with_attribute(db, ca)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{attribute_definition_id}")
|
|
||||||
async def delete_category_attribute(
|
|
||||||
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
ca = await db.scalar(
|
|
||||||
select(CategoryAttribute).where(
|
|
||||||
CategoryAttribute.category_id == category_id,
|
|
||||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if ca is None:
|
|
||||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
|
||||||
await db.delete(ca)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": {"deleted": True}}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Category reference units — units.html dashboard (P2-26/27).
|
|
||||||
|
|
||||||
One reference unit per category (UNIQUE category_id); PUT is an upsert.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.engine import get_db
|
|
||||||
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
|
|
||||||
from app.schemas.catalog import CategoryReferenceUnitPut
|
|
||||||
from app.services.expand_service import category_brief
|
|
||||||
from app.utils.errors import NotFoundError
|
|
||||||
from app.utils.pagination import paginate
|
|
||||||
from app.utils.serialization import to_iso
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
|
|
||||||
|
|
||||||
|
|
||||||
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
|
|
||||||
d = {
|
|
||||||
"id": str(cru.id),
|
|
||||||
"category_id": str(cru.category_id),
|
|
||||||
"ref_unit": cru.ref_unit,
|
|
||||||
"updated_at": to_iso(cru.updated_at),
|
|
||||||
}
|
|
||||||
if "category" in expand and hasattr(cru, "_category"):
|
|
||||||
d["category"] = category_brief(cru._category)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_category_reference_units(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
category_id=None,
|
|
||||||
expand: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
stmt = select(CategoryReferenceUnit)
|
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
|
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
|
||||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
|
||||||
if "category" in tokens:
|
|
||||||
cat_ids = {cru.category_id for cru in items}
|
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
for cru in items:
|
|
||||||
cru._category = cats.get(cru.category_id)
|
|
||||||
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{category_id}")
|
|
||||||
async def upsert_category_reference_unit(
|
|
||||||
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
category = await db.get(MaterialCategory, category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
|
||||||
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
|
|
||||||
if cru is None:
|
|
||||||
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
|
|
||||||
db.add(cru)
|
|
||||||
else:
|
|
||||||
cru.ref_unit = payload.ref_unit
|
|
||||||
await db.flush()
|
|
||||||
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
|
|
||||||
@@ -314,7 +314,6 @@ async def delete_warehouse(
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_inventory(
|
async def list_inventory(
|
||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
category_id=None,
|
|
||||||
warehouse_id=None,
|
warehouse_id=None,
|
||||||
material_id=None,
|
material_id=None,
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
@@ -332,10 +331,6 @@ async def list_inventory(
|
|||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
Warehouse.project_id == project_id
|
Warehouse.project_id == project_id
|
||||||
)
|
)
|
||||||
if category_id is not None:
|
|
||||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
|
||||||
Material.category_id == category_id
|
|
||||||
)
|
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||||
if material_id is not None:
|
if material_id is not None:
|
||||||
@@ -357,10 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
unit_ids = {i.unit_id for i in items}
|
unit_ids = {i.unit_id for i in items}
|
||||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
categories = {}
|
|
||||||
if materials:
|
|
||||||
cat_ids = {m.category_id for m in materials.values()}
|
|
||||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
brands = {}
|
brands = {}
|
||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||||
@@ -370,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
return [
|
return [
|
||||||
inventory_to_dict(
|
inventory_to_dict(
|
||||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
None, brands.get(i.brand_id), units.get(i.unit_id),
|
||||||
)
|
)
|
||||||
for i in items
|
for i in items
|
||||||
]
|
]
|
||||||
@@ -532,58 +523,3 @@ async def transaction_types():
|
|||||||
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
||||||
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
||||||
)]}
|
)]}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Material selector — filtered by Category + Warehouse (transaction forms)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/materials/options")
|
|
||||||
async def material_options(
|
|
||||||
category_id: uuid.UUID,
|
|
||||||
warehouse_id: uuid.UUID | None = None,
|
|
||||||
scope: Scope = Depends(inventory_scope),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Materials for the transaction form.
|
|
||||||
|
|
||||||
Always filtered by category. When `warehouse_id` is given, only materials
|
|
||||||
that already exist in that warehouse's inventory are returned (so the PM
|
|
||||||
works with the actual stock of that warehouse); otherwise the full
|
|
||||||
category list is returned (e.g. first purchase of a new material).
|
|
||||||
"""
|
|
||||||
category = await db.get(MaterialCategory, category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
|
|
||||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
|
||||||
if warehouse_id is not None:
|
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
|
||||||
stmt = stmt.where(
|
|
||||||
Material.id.in_(
|
|
||||||
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Material.name)
|
|
||||||
materials = (await db.execute(stmt)).scalars().all()
|
|
||||||
|
|
||||||
# Brands per material (from brand_materials catalog + brands actually used in stock)
|
|
||||||
brand_rows = (await db.execute(
|
|
||||||
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
|
||||||
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
|
||||||
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
|
|
||||||
)).all()
|
|
||||||
brands_by_material: dict[str, list[dict]] = {}
|
|
||||||
for mat_id, b_id, b_name in brand_rows:
|
|
||||||
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
|
|
||||||
|
|
||||||
return {"data": [
|
|
||||||
{
|
|
||||||
"id": str(m.id),
|
|
||||||
"name": m.name,
|
|
||||||
"brands": brands_by_material.get(str(m.id), []),
|
|
||||||
}
|
|
||||||
for m in materials
|
|
||||||
]}
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
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 MaterialCategory
|
||||||
|
from app.models.procurement import Supplier
|
||||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
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("دستهبندی یافت نشد")
|
||||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
# v2.7: materials no longer carry a category, so what blocks deletion is the
|
||||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
|
||||||
|
# material of that category is reached through those suppliers.
|
||||||
|
linked = await db.scalar(
|
||||||
|
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
|
||||||
|
)
|
||||||
if linked:
|
if linked:
|
||||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
raise ConflictError(f"این دستهبندی برای {linked} تأمینکننده ثبت شده و قابل حذف نیست")
|
||||||
await db.delete(category) # category_attributes cascade via DDL
|
await db.delete(category) # category_attributes cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": {"id": str(category_id), "deleted": True}}
|
return {"data": {"id": str(category_id), "deleted": True}}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.models.catalog import (
|
|||||||
MaterialUnit,
|
MaterialUnit,
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
from app.models.procurement import LineItem
|
||||||
from app.schemas.catalog import (
|
from app.schemas.catalog import (
|
||||||
MaterialAttributeValuesPut,
|
MaterialAttributeValuesPut,
|
||||||
MaterialCreate,
|
MaterialCreate,
|
||||||
@@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
|
|||||||
async def list_materials(
|
async def list_materials(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
category_id=None,
|
|
||||||
brand_id=None,
|
brand_id=None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -107,17 +106,6 @@ async def list_materials(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(Material)
|
stmt = select(Material)
|
||||||
if category_id:
|
|
||||||
sub = (
|
|
||||||
select(1)
|
|
||||||
.select_from(SupplierMaterial)
|
|
||||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
||||||
.where(
|
|
||||||
SupplierMaterial.material_id == Material.id,
|
|
||||||
Supplier.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stmt = stmt.where(exists(sub))
|
|
||||||
if brand_id:
|
if brand_id:
|
||||||
# WHERE EXISTS brand_materials with that brand_id
|
# WHERE EXISTS brand_materials with that brand_id
|
||||||
sub = select(1).where(
|
sub = select(1).where(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
from app.models.catalog import BrandMaterial, Material, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -201,12 +201,6 @@ async def pm_on_the_way(
|
|||||||
# unit / category lookups for display
|
# unit / category lookups for display
|
||||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||||
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
|
||||||
cats = {}
|
|
||||||
if sup_cat_ids:
|
|
||||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
|
||||||
cats = {c.id: c for c in rows}
|
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m, sup, proj in decisions:
|
for d, li, q, m, sup, proj in decisions:
|
||||||
received = received_map.get(d.id)
|
received = received_map.get(d.id)
|
||||||
@@ -219,8 +213,7 @@ async def pm_on_the_way(
|
|||||||
"supplier": {"id": str(sup.id), "name": sup.name},
|
"supplier": {"id": str(sup.id), "name": sup.name},
|
||||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name,
|
"material": {"id": str(m.id), "name": m.name},
|
||||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
|
|
||||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||||
"ordered_qty": str(li.quantity),
|
"ordered_qty": str(li.quantity),
|
||||||
|
|||||||
@@ -45,14 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
|
|||||||
|
|
||||||
# --- list filter helpers ---------------------------------------------------
|
# --- list filter helpers ---------------------------------------------------
|
||||||
|
|
||||||
def _quote_category_exists(category_id) -> exists:
|
|
||||||
return exists(
|
|
||||||
select(1).where(
|
|
||||||
Supplier.id == Quote.supplier_id,
|
|
||||||
Supplier.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_search(q: str) -> exists:
|
def _quote_search(q: str) -> exists:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
@@ -80,7 +72,6 @@ async def get_quote_stats(
|
|||||||
project_id=None,
|
project_id=None,
|
||||||
quote_date_from: datetime | None = None,
|
quote_date_from: datetime | None = None,
|
||||||
quote_date_to: datetime | None = None,
|
quote_date_to: datetime | None = None,
|
||||||
category_id=None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return {
|
return {
|
||||||
@@ -90,7 +81,6 @@ async def get_quote_stats(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
quote_date_from=quote_date_from,
|
quote_date_from=quote_date_from,
|
||||||
quote_date_to=quote_date_to,
|
quote_date_to=quote_date_to,
|
||||||
category_id=category_id,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +96,6 @@ async def list_quotes(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
supplier_id=None,
|
supplier_id=None,
|
||||||
project_id=None,
|
project_id=None,
|
||||||
category_id=None,
|
|
||||||
quote_date_from: datetime | None = None,
|
quote_date_from: datetime | None = None,
|
||||||
quote_date_to: datetime | None = None,
|
quote_date_to: datetime | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -137,8 +126,6 @@ async def list_quotes(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(_quote_category_exists(category_id))
|
|
||||||
if quote_date_from is not None:
|
if quote_date_from is not None:
|
||||||
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
||||||
if quote_date_to is not None:
|
if quote_date_to is not None:
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
|
|
||||||
|
|
||||||
Model: {category_id, brand?, from_unit, to_unit, factor}
|
|
||||||
1 from_unit = factor × to_unit
|
|
||||||
brand empty = all brands of the category; brand set = that brand only.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.engine import get_db
|
|
||||||
from app.models.catalog import MaterialCategory, UnitConversion
|
|
||||||
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
|
|
||||||
from app.services.expand_service import category_brief
|
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
|
||||||
from app.utils.pagination import paginate
|
|
||||||
from app.utils.serialization import to_iso
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
|
|
||||||
|
|
||||||
|
|
||||||
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
|
|
||||||
d = {
|
|
||||||
"id": str(uc.id),
|
|
||||||
"category_id": str(uc.category_id),
|
|
||||||
"brand": uc.brand,
|
|
||||||
"from_unit": uc.from_unit,
|
|
||||||
"to_unit": uc.to_unit,
|
|
||||||
"factor": float(uc.factor),
|
|
||||||
"updated_at": to_iso(uc.updated_at),
|
|
||||||
}
|
|
||||||
if "category" in expand and hasattr(uc, "_category"):
|
|
||||||
d["category"] = category_brief(uc._category)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
|
|
||||||
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
|
|
||||||
where = (
|
|
||||||
UnitConversion.category_id == category_id,
|
|
||||||
UnitConversion.from_unit == from_unit,
|
|
||||||
UnitConversion.to_unit == to_unit,
|
|
||||||
)
|
|
||||||
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده میشود
|
|
||||||
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
|
|
||||||
if uc_or_none is not None:
|
|
||||||
where = where + (UnitConversion.id != uc_or_none.id,)
|
|
||||||
return where
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
|
|
||||||
duplicate = await db.scalar(
|
|
||||||
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
|
|
||||||
)
|
|
||||||
if duplicate is not None:
|
|
||||||
raise ConflictError("این تبدیل قبلاً ثبت شده است")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_unit_conversions(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
category_id=None,
|
|
||||||
brand: str | None = None,
|
|
||||||
from_unit: str | None = None,
|
|
||||||
to_unit: str | None = None,
|
|
||||||
expand: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
stmt = select(UnitConversion)
|
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(UnitConversion.category_id == category_id)
|
|
||||||
if brand:
|
|
||||||
stmt = stmt.where(UnitConversion.brand == brand)
|
|
||||||
if from_unit:
|
|
||||||
stmt = stmt.where(UnitConversion.from_unit == from_unit)
|
|
||||||
if to_unit:
|
|
||||||
stmt = stmt.where(UnitConversion.to_unit == to_unit)
|
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
|
||||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
|
||||||
if "category" in tokens:
|
|
||||||
cat_ids = {uc.category_id for uc in items}
|
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
for uc in items:
|
|
||||||
uc._category = cats.get(uc.category_id)
|
|
||||||
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
|
||||||
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
category = await db.get(MaterialCategory, payload.category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
|
|
||||||
uc = UnitConversion(
|
|
||||||
category_id=payload.category_id,
|
|
||||||
brand=payload.brand,
|
|
||||||
from_unit=payload.from_unit,
|
|
||||||
to_unit=payload.to_unit,
|
|
||||||
factor=payload.factor,
|
|
||||||
)
|
|
||||||
db.add(uc)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": uc_to_dict(uc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{uc_id}")
|
|
||||||
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
|
||||||
uc = await db.get(UnitConversion, uc_id)
|
|
||||||
if uc is None:
|
|
||||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
|
||||||
return {"data": uc_to_dict(uc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{uc_id}")
|
|
||||||
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
|
|
||||||
uc = await db.get(UnitConversion, uc_id)
|
|
||||||
if uc is None:
|
|
||||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
|
||||||
new_brand = payload.brand if payload.brand is not None else uc.brand
|
|
||||||
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
|
|
||||||
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
|
|
||||||
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
|
|
||||||
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
|
|
||||||
if payload.brand is not None:
|
|
||||||
uc.brand = payload.brand
|
|
||||||
if payload.from_unit is not None:
|
|
||||||
uc.from_unit = payload.from_unit
|
|
||||||
if payload.to_unit is not None:
|
|
||||||
uc.to_unit = payload.to_unit
|
|
||||||
if payload.factor is not None:
|
|
||||||
uc.factor = payload.factor
|
|
||||||
await db.flush()
|
|
||||||
return {"data": uc_to_dict(uc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{uc_id}")
|
|
||||||
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
|
||||||
uc = await db.get(UnitConversion, uc_id)
|
|
||||||
if uc is None:
|
|
||||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
|
||||||
await db.delete(uc)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": {"deleted": True}}
|
|
||||||
@@ -37,13 +37,14 @@ async def track(event: str, properties: dict[str, Any] | None = None, profile_id
|
|||||||
"""Record a named event; swallows every error."""
|
"""Record a named event; swallows every error."""
|
||||||
if not _configured():
|
if not _configured():
|
||||||
return
|
return
|
||||||
body = {
|
# OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
|
||||||
"type": "track",
|
# sends) — a flat body is rejected with 400, which this module swallows by design.
|
||||||
"event": event,
|
# The payload field is "name" (ITrackPayload in packages/validation) — "event" is
|
||||||
"properties": properties or {},
|
# rejected with a 400 that this module swallows.
|
||||||
}
|
inner: dict[str, Any] = {"name": event, "properties": properties or {}}
|
||||||
if profile_id:
|
if profile_id:
|
||||||
body["profileId"] = profile_id
|
inner["profileId"] = profile_id
|
||||||
|
body = {"type": "track", "payload": inner}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
await client.post(
|
await client.post(
|
||||||
@@ -66,7 +67,7 @@ async def identify(profile_id: str, properties: dict[str, Any] | None = None) ->
|
|||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
await client.post(
|
await client.post(
|
||||||
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
|
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
|
||||||
json={"type": "identify", "profileId": profile_id, "properties": properties or {}},
|
json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
|
||||||
headers={
|
headers={
|
||||||
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
|
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
|
||||||
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
|
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
|||||||
"created_at": to_iso(m.created_at),
|
"created_at": to_iso(m.created_at),
|
||||||
"updated_at": to_iso(m.updated_at),
|
"updated_at": to_iso(m.updated_at),
|
||||||
}
|
}
|
||||||
|
# Category is a supplier attribute now, not a material one — the key stays
|
||||||
|
# in the payload (null) so the contract shape is unchanged.
|
||||||
if "category" in expand:
|
if "category" in expand:
|
||||||
d["category"] = None
|
d["category"] = None
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -873,7 +873,6 @@ async def quote_stats(
|
|||||||
project_id: UUID | None = None,
|
project_id: UUID | None = None,
|
||||||
quote_date_from=None,
|
quote_date_from=None,
|
||||||
quote_date_to=None,
|
quote_date_to=None,
|
||||||
category_id: UUID | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
conditions = []
|
conditions = []
|
||||||
if supplier_id is not None:
|
if supplier_id is not None:
|
||||||
@@ -884,8 +883,6 @@ async def quote_stats(
|
|||||||
conditions.append(Quote.quote_date >= quote_date_from)
|
conditions.append(Quote.quote_date >= quote_date_from)
|
||||||
if quote_date_to is not None:
|
if quote_date_to is not None:
|
||||||
conditions.append(Quote.quote_date <= quote_date_to)
|
conditions.append(Quote.quote_date <= quote_date_to)
|
||||||
if category_id is not None:
|
|
||||||
conditions.append(_quote_has_category(category_id))
|
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -917,18 +914,6 @@ async def quote_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _quote_has_category(category_id: UUID):
|
|
||||||
"""EXISTS: quote's supplier belongs to category_id."""
|
|
||||||
sub = (
|
|
||||||
select(1)
|
|
||||||
.where(
|
|
||||||
Supplier.id == Quote.supplier_id,
|
|
||||||
Supplier.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return func.exists(sub)
|
|
||||||
|
|
||||||
|
|
||||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||||
"""Counts + total value per quote status for ONE project, isolated per project.
|
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ async def run() -> None:
|
|||||||
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
|
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
|
||||||
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
|
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
|
||||||
"material_attribute_values, materials, brands, warehouses, projects, users, "
|
"material_attribute_values, materials, brands, warehouses, projects, users, "
|
||||||
"category_reference_units, unit_conversions, standard_conversions "
|
"standard_conversions "
|
||||||
"RESTART IDENTITY CASCADE"
|
"RESTART IDENTITY CASCADE"
|
||||||
))
|
))
|
||||||
# Only the 8 canonical topic categories may exist — any leftover slug
|
# Only the 8 canonical topic categories may exist — any leftover slug
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
|||||||
# Empty values disable the feature (analytics.ts is a no-op then).
|
# Empty values disable the feature (analytics.ts is a no-op then).
|
||||||
ARG VITE_OPENPANEL_URL=""
|
ARG VITE_OPENPANEL_URL=""
|
||||||
ARG VITE_OPENPANEL_CLIENT_ID=""
|
ARG VITE_OPENPANEL_CLIENT_ID=""
|
||||||
|
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
|
||||||
|
# so it needs its own arg — defined here or the value never reaches the bundle.
|
||||||
|
ARG VITE_OPENPANEL_SCRIPT_URL=""
|
||||||
ARG VITE_SENTRY_DSN=""
|
ARG VITE_SENTRY_DSN=""
|
||||||
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
|
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
|
||||||
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
|
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
|
||||||
|
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
|
||||||
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
||||||
|
|
||||||
# Source code + build
|
# Source code + build
|
||||||
|
|||||||
@@ -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,17 +96,12 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use
|
|||||||
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 } from "./useProjects";
|
||||||
export {
|
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
||||||
useUnits,
|
|
||||||
useSaveRefUnit,
|
|
||||||
useSaveConversion,
|
|
||||||
useDeleteConversion,
|
|
||||||
useSaveStandardConversion,
|
|
||||||
useDeleteStandardConversion,
|
|
||||||
} from "./useUnits";
|
|
||||||
|
|||||||
@@ -1,52 +1,16 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// hooks واحدها — واحد مرجع، ضرایب دستهای و ثابت
|
// hooks واحدها — تبدیلهای ثابت جهانی (دستهایها حذف شدند)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types";
|
import type { StandardConversion } from "../lib/types";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { useAppStore } from "../store";
|
import { useAppStore } from "../store";
|
||||||
import { useInvalidateStore } from "./useStoreData";
|
import { useInvalidateStore } from "./useStoreData";
|
||||||
|
|
||||||
export function useUnits(): {
|
export function useUnits(): { standardConversions: StandardConversion[] } {
|
||||||
categoryUnits: CategoryUnit[];
|
|
||||||
unitConversions: UnitConversion[];
|
|
||||||
standardConversions: StandardConversion[];
|
|
||||||
} {
|
|
||||||
const categoryUnits = useAppStore((s) => s.categoryUnits);
|
|
||||||
const unitConversions = useAppStore((s) => s.unitConversions);
|
|
||||||
const standardConversions = useAppStore((s) => s.standardConversions);
|
const standardConversions = useAppStore((s) => s.standardConversions);
|
||||||
return { categoryUnits, unitConversions, standardConversions };
|
return { standardConversions };
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveRefUnit() {
|
|
||||||
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
|
|
||||||
const invalidate = useInvalidateStore();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
|
|
||||||
onMutate: (cu) => setCategoryUnit(cu),
|
|
||||||
onSettled: () => invalidate(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveConversion() {
|
|
||||||
const setConversion = useAppStore((s) => s.setConversion);
|
|
||||||
const invalidate = useInvalidateStore();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
|
|
||||||
onMutate: (c) => setConversion(c),
|
|
||||||
onSettled: () => invalidate(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteConversion() {
|
|
||||||
const deleteConversion = useAppStore((s) => s.deleteConversion);
|
|
||||||
const invalidate = useInvalidateStore();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => api.units.deleteConversion(id),
|
|
||||||
onMutate: (id) => deleteConversion(id),
|
|
||||||
onSettled: () => invalidate(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveStandardConversion() {
|
export function useSaveStandardConversion() {
|
||||||
|
|||||||
@@ -22,8 +22,31 @@ declare global {
|
|||||||
|
|
||||||
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
|
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
|
||||||
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
|
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
|
||||||
|
const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim();
|
||||||
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
|
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the OpenPanel browser script (op1.js) and its queueing shim.
|
||||||
|
*
|
||||||
|
* The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the
|
||||||
|
* script is in flight, so `op("init", …)` can be called immediately and the real
|
||||||
|
* script drains the queue on load. Without it, every `window.op?.()` call here is a
|
||||||
|
* silent no-op — which is what happened before this loader existed.
|
||||||
|
*/
|
||||||
|
function loadOpenPanelScript(src: string): void {
|
||||||
|
if (document.querySelector("script[data-openpanel]")) return;
|
||||||
|
const shim = document.createElement("script");
|
||||||
|
shim.textContent =
|
||||||
|
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();';
|
||||||
|
document.head.appendChild(shim);
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.dataset.openpanel = "1";
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
export function initAnalytics(): void {
|
export function initAnalytics(): void {
|
||||||
if (SENTRY_DSN) {
|
if (SENTRY_DSN) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
@@ -35,6 +58,7 @@ export function initAnalytics(): void {
|
|||||||
}
|
}
|
||||||
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
|
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
|
||||||
try {
|
try {
|
||||||
|
if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL);
|
||||||
window.op?.("init", {
|
window.op?.("init", {
|
||||||
apiUrl: OPENPANEL_URL,
|
apiUrl: OPENPANEL_URL,
|
||||||
clientId: OPENPANEL_CLIENT_ID,
|
clientId: OPENPANEL_CLIENT_ID,
|
||||||
|
|||||||
+13
-112
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
|||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
CategoryUnit,
|
|
||||||
ConfirmedReceipt,
|
ConfirmedReceipt,
|
||||||
InventoryRow,
|
InventoryRow,
|
||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
@@ -30,7 +29,6 @@ import type {
|
|||||||
Store,
|
Store,
|
||||||
Supplier,
|
Supplier,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
UnitConversion,
|
|
||||||
Warehouse,
|
Warehouse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -252,10 +250,6 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
|
|||||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
async function putData<T>(path: string, body: unknown): Promise<T> {
|
|
||||||
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
async function deleteData<T>(path: string): Promise<T> {
|
async function deleteData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -322,7 +316,6 @@ interface RawSupplier {
|
|||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
contact_person?: string | null;
|
contact_person?: string | null;
|
||||||
tax_id?: string | null;
|
tax_id?: string | null;
|
||||||
categories?: string[] | null; // متریالهای تأمینشده
|
|
||||||
// v2.6: هر تأمینکننده دقیقاً یک دسته دارد (FK) — بکاند این را میفرستد
|
// v2.6: هر تأمینکننده دقیقاً یک دسته دارد (FK) — بکاند این را میفرستد
|
||||||
category_id?: string | null;
|
category_id?: string | null;
|
||||||
category?: { id: string; slug: string; name: string } | null;
|
category?: { id: string; slug: string; name: string } | null;
|
||||||
@@ -402,21 +395,6 @@ interface RawQuote {
|
|||||||
line_items?: RawLineItem[] | null;
|
line_items?: RawLineItem[] | null;
|
||||||
termsheets?: RawTermsheet[] | null;
|
termsheets?: RawTermsheet[] | null;
|
||||||
}
|
}
|
||||||
interface RawCategoryUnit {
|
|
||||||
id: string;
|
|
||||||
category_id: string;
|
|
||||||
ref_unit: string;
|
|
||||||
category?: RawCategory | null;
|
|
||||||
}
|
|
||||||
interface RawUnitConversion {
|
|
||||||
id: string;
|
|
||||||
category_id: string;
|
|
||||||
brand?: string | null;
|
|
||||||
from_unit: string;
|
|
||||||
to_unit: string;
|
|
||||||
factor: number;
|
|
||||||
category?: RawCategory | null;
|
|
||||||
}
|
|
||||||
interface RawStandardConversion {
|
interface RawStandardConversion {
|
||||||
id: string;
|
id: string;
|
||||||
from_unit: string;
|
from_unit: string;
|
||||||
@@ -562,7 +540,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
|||||||
topic:
|
topic:
|
||||||
raw.topic ||
|
raw.topic ||
|
||||||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
||||||
category: raw.category?.name || "",
|
|
||||||
brand: raw.brands?.[0]?.name || "",
|
brand: raw.brands?.[0]?.name || "",
|
||||||
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
||||||
specs: (raw.attributes || [])
|
specs: (raw.attributes || [])
|
||||||
@@ -643,11 +620,13 @@ function adaptQuote(raw: RawQuote): Quote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
||||||
|
// هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل category
|
||||||
|
// برمیگرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
|
||||||
|
const own = raw.category?.name ? [raw.category.name] : [];
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
topic,
|
topic,
|
||||||
// دستههای ذخیرهشده روی تأمینکننده (بکاند) اولویت دارند؛ وگرنه از quotes استنتاج میشود
|
categories: own.length ? own : categories,
|
||||||
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
|
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
location: raw.location || "",
|
location: raw.location || "",
|
||||||
phone: raw.phone || "",
|
phone: raw.phone || "",
|
||||||
@@ -665,54 +644,29 @@ function deriveSupplierTopics(
|
|||||||
materialsById: Map<string, Material>,
|
materialsById: Map<string, Material>,
|
||||||
): Supplier[] {
|
): Supplier[] {
|
||||||
const topics = new Map<string, Set<string>>();
|
const topics = new Map<string, Set<string>>();
|
||||||
const cats = new Map<string, Set<string>>();
|
|
||||||
for (const q of quotes) {
|
for (const q of quotes) {
|
||||||
for (const li of q.line_items) {
|
for (const li of q.line_items) {
|
||||||
const m = materialsById.get(li.material_id);
|
const m = materialsById.get(li.material_id);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
||||||
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
|
|
||||||
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
|
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
|
||||||
if (m.category) cats.get(q.supplier_id)!.add(m.category);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rawSuppliers.map((s) => {
|
return rawSuppliers.map((s) => {
|
||||||
const t = topics.get(s.id);
|
const t = topics.get(s.id);
|
||||||
const c = cats.get(s.id);
|
|
||||||
const derivedTopic = t && t.size ? [...t][0] : "";
|
const derivedTopic = t && t.size ? [...t][0] : "";
|
||||||
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
|
|
||||||
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
||||||
if (!derivedTopic && !derivedCats.length) {
|
if (!derivedTopic) {
|
||||||
const reg = supplierTopicRegistry.get(s.id);
|
const reg = supplierTopicRegistry.get(s.id);
|
||||||
if (reg) {
|
if (reg) return adaptSupplier(s, reg.topic, reg.categories);
|
||||||
return adaptSupplier(s, reg.topic, reg.categories);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// v2.7: متریالها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
|
// دستهٔ ثبتشده روی خودِ تأمینکننده (FK بکاند) منبع اصلی است و نامش با نام
|
||||||
// دستهای نمیدهد و صفحهی تأمینکنندهها بینام میشد. دستهٔ ثبتشده روی
|
// تاپیکها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج میشود.
|
||||||
// خودِ تأمینکننده (FK بکاند) منبع اصلی است؛ نامش با نام تاپیکها یکی است.
|
|
||||||
const backendCat = s.category?.name || "";
|
const backendCat = s.category?.name || "";
|
||||||
return adaptSupplier(
|
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
|
||||||
s,
|
|
||||||
derivedTopic || backendCat,
|
|
||||||
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
|
|
||||||
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
|
|
||||||
}
|
|
||||||
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
|
|
||||||
return {
|
|
||||||
id: raw.id,
|
|
||||||
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
|
|
||||||
brand: raw.brand || "",
|
|
||||||
from_unit: raw.from_unit,
|
|
||||||
to_unit: raw.to_unit,
|
|
||||||
factor: raw.factor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
|
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
|
||||||
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
|
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
|
||||||
}
|
}
|
||||||
@@ -830,16 +784,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
|
|||||||
export const api = {
|
export const api = {
|
||||||
// ---------- کل store (یک بار در ریشه) ----------
|
// ---------- کل store (یک بار در ریشه) ----------
|
||||||
async listAll(): Promise<Store> {
|
async listAll(): Promise<Store> {
|
||||||
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] =
|
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
|
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
|
||||||
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
|
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
|
||||||
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
|
getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
|
||||||
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
|
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
|
||||||
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
|
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
|
||||||
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
|
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
|
||||||
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
|
|
||||||
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
|
|
||||||
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
|
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -854,8 +806,6 @@ export const api = {
|
|||||||
materials,
|
materials,
|
||||||
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
|
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
|
||||||
quotes,
|
quotes,
|
||||||
categoryUnits: cruRes.map(adaptCategoryUnit),
|
|
||||||
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
|
|
||||||
standardConversions: scuRes.map(adaptStandardConversion),
|
standardConversions: scuRes.map(adaptStandardConversion),
|
||||||
topics: buildTopics(categoriesRes),
|
topics: buildTopics(categoriesRes),
|
||||||
};
|
};
|
||||||
@@ -1130,7 +1080,7 @@ export const api = {
|
|||||||
|
|
||||||
materials: {
|
materials: {
|
||||||
list: async (): Promise<Material[]> => {
|
list: async (): Promise<Material[]> => {
|
||||||
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
|
const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
|
||||||
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
|
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
|
||||||
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
|
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
|
||||||
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
|
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
|
||||||
@@ -1149,7 +1099,7 @@ export const api = {
|
|||||||
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
||||||
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
||||||
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
||||||
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||||
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
||||||
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -1190,46 +1140,6 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
units: {
|
units: {
|
||||||
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
|
|
||||||
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
|
|
||||||
return raw.map(adaptCategoryUnit);
|
|
||||||
},
|
|
||||||
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
|
|
||||||
const lookup = await fetchLookup();
|
|
||||||
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
|
|
||||||
if (!categoryId) throw new Error(`دستهبندی «${cu.category}» یافت نشد`);
|
|
||||||
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
|
|
||||||
},
|
|
||||||
getConversions: async (): Promise<UnitConversion[]> => {
|
|
||||||
const lookup = await fetchLookup();
|
|
||||||
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
|
|
||||||
return raw.map((uc) => adaptUnitConversion(uc, lookup));
|
|
||||||
},
|
|
||||||
saveConversion: async (c: UnitConversion): Promise<void> => {
|
|
||||||
const lookup = await fetchLookup();
|
|
||||||
const categoryId = lookup.categoryNameToId.get(norm(c.category));
|
|
||||||
if (!categoryId) throw new Error(`دستهبندی «${c.category}» یافت نشد`);
|
|
||||||
const body = {
|
|
||||||
category_id: categoryId,
|
|
||||||
brand: c.brand || null,
|
|
||||||
from_unit: c.from_unit,
|
|
||||||
to_unit: c.to_unit,
|
|
||||||
factor: c.factor,
|
|
||||||
};
|
|
||||||
if (looksLikeUuid(c.id)) {
|
|
||||||
await patchData(`/unit-conversions/${c.id}`, {
|
|
||||||
brand: c.brand || null,
|
|
||||||
from_unit: c.from_unit,
|
|
||||||
to_unit: c.to_unit,
|
|
||||||
factor: c.factor,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await postData("/unit-conversions", body);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteConversion: async (id: string): Promise<void> => {
|
|
||||||
await deleteData(`/unit-conversions/${id}`);
|
|
||||||
},
|
|
||||||
getStandardConversions: async (): Promise<StandardConversion[]> => {
|
getStandardConversions: async (): Promise<StandardConversion[]> => {
|
||||||
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
|
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
|
||||||
return raw.map(adaptStandardConversion);
|
return raw.map(adaptStandardConversion);
|
||||||
@@ -1775,15 +1685,6 @@ export const apiV26 = {
|
|||||||
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
|
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
|
||||||
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
|
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
|
||||||
},
|
},
|
||||||
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
|
|
||||||
const params: Record<string, string> = { category_id };
|
|
||||||
if (warehouse_id) params.warehouse_id = warehouse_id;
|
|
||||||
const pid = activeProjectId();
|
|
||||||
if (pid) params.project_id = pid;
|
|
||||||
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
|
|
||||||
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
pm: {
|
pm: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
|
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types";
|
import type { LineItem, Material, Quote, Store, Supplier } from "./types";
|
||||||
import { normalize } from "./normalize";
|
import { normalize } from "./normalize";
|
||||||
import { groupCommas } from "./numutil";
|
import { groupCommas } from "./numutil";
|
||||||
import { fmtDate } from "./formatters";
|
import { fmtDate } from "./formatters";
|
||||||
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
|
|||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
topic: "",
|
topic: "",
|
||||||
category: "؟",
|
|
||||||
brand: "",
|
brand: "",
|
||||||
specs: [],
|
specs: [],
|
||||||
base_unit: "",
|
base_unit: "",
|
||||||
@@ -46,7 +45,7 @@ export function specsLabel(specs: string[]): string {
|
|||||||
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||||
*/
|
*/
|
||||||
export function formatMaterialTitle(
|
export function formatMaterialTitle(
|
||||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
|
||||||
extraSpecs?: string[],
|
extraSpecs?: string[],
|
||||||
): string {
|
): string {
|
||||||
if (!m) return "کالا";
|
if (!m) return "کالا";
|
||||||
@@ -75,7 +74,7 @@ export function formatMaterialTitle(
|
|||||||
if (rawName) {
|
if (rawName) {
|
||||||
parts.push(rawName);
|
parts.push(rawName);
|
||||||
} else if (!rawBrand) {
|
} else if (!rawBrand) {
|
||||||
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
|
parts.push("کالا");
|
||||||
}
|
}
|
||||||
|
|
||||||
// اضافه کردن مشخصهها بدون تکرار
|
// اضافه کردن مشخصهها بدون تکرار
|
||||||
@@ -95,10 +94,67 @@ export function formatMaterialTitle(
|
|||||||
return parts.join(" . ");
|
return parts.join(" . ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
|
||||||
return formatMaterialTitle(m);
|
return formatMaterialTitle(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||||
|
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
||||||
|
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||||
|
// name را به شکل «برند + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا نامِ
|
||||||
|
// پایه باقی بماند.
|
||||||
|
export function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||||
|
const fallback = (name || "").trim();
|
||||||
|
if (!fallback) return "";
|
||||||
|
let base = fallback;
|
||||||
|
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||||
|
const parts = [brand, ...specs]
|
||||||
|
.map((p) => (p || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!base.includes(p)) continue;
|
||||||
|
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||||
|
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||||
|
if (!next) continue;
|
||||||
|
base = next;
|
||||||
|
}
|
||||||
|
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||||
|
if (parts.some((p) => p === base)) return fallback;
|
||||||
|
return base || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
|
||||||
|
*
|
||||||
|
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
|
||||||
|
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||||
|
* برند و مشخصهی یکسان (مثل دو چمن مصنوعیِ همبرند) را میگیرد. نامِ خالی یعنی
|
||||||
|
* هویت نامعلوم — در آن حالت تطبیق نمیدهیم و متریالِ تازه ساخته میشود.
|
||||||
|
*/
|
||||||
|
export function matchMaterial(
|
||||||
|
materials: Material[],
|
||||||
|
name: string,
|
||||||
|
brand: string,
|
||||||
|
specs: string[],
|
||||||
|
): Material | undefined {
|
||||||
|
const wantName = normalize(name);
|
||||||
|
if (!wantName) return undefined;
|
||||||
|
const wantBrand = normalize(brand);
|
||||||
|
const wantSpecs = specsKey(specs);
|
||||||
|
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
|
||||||
|
const base = normalize(materialBaseName(name, brand, specs));
|
||||||
|
return (
|
||||||
|
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
|
||||||
|
materials.find(
|
||||||
|
(m) =>
|
||||||
|
base &&
|
||||||
|
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
|
||||||
|
sameIdentity(m),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||||
export function itemTermsheetIds(li: LineItem): string[] {
|
export function itemTermsheetIds(li: LineItem): string[] {
|
||||||
if (li.termsheet_ids) return li.termsheet_ids;
|
if (li.termsheet_ids) return li.termsheet_ids;
|
||||||
@@ -158,54 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
|
|||||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
|
||||||
|
|
||||||
// واحد مرجع یک دسته
|
|
||||||
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
|
|
||||||
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
|
|
||||||
return cu ? cu.ref_unit : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ضریب تبدیل با اولویت: دستهای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
|
|
||||||
export function findConversion(
|
|
||||||
store: Pick<Store, "unitConversions" | "standardConversions">,
|
|
||||||
category: string,
|
|
||||||
brand: string,
|
|
||||||
fromUnit: string,
|
|
||||||
toUnit: string,
|
|
||||||
): UnitConversion | StandardConversion | null {
|
|
||||||
const list = (store.unitConversions || []).filter(
|
|
||||||
(c) =>
|
|
||||||
normalize(c.category) === normalize(category) &&
|
|
||||||
normalize(c.from_unit) === normalize(fromUnit) &&
|
|
||||||
normalize(c.to_unit) === normalize(toUnit) &&
|
|
||||||
(!c.brand || normalize(c.brand) === normalize(brand)),
|
|
||||||
);
|
|
||||||
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
|
|
||||||
if (list[0]) return list[0];
|
|
||||||
return (
|
|
||||||
(store.standardConversions || []).find(
|
|
||||||
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
|
|
||||||
export function refConverted(
|
|
||||||
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
|
|
||||||
li: LineItem,
|
|
||||||
): { qty: number; unit: string; perRef: number | null } | null {
|
|
||||||
const m = materialById(store.materials, li.material_id);
|
|
||||||
const ref = refUnitForCategory(store, m.category);
|
|
||||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
|
||||||
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
|
||||||
if (!conv) return null;
|
|
||||||
const qty = (li.quantity || 0) * conv.factor;
|
|
||||||
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
|
||||||
const perRef = qty > 0 ? Math.round(total / qty) : null;
|
|
||||||
return { qty, unit: ref, perRef };
|
|
||||||
}
|
|
||||||
|
|
||||||
// واحدهای ناشناختهی استفادهشده در یک quote → لینک به داشبورد واحدها
|
// واحدهای ناشناختهی استفادهشده در یک quote → لینک به داشبورد واحدها
|
||||||
// واحدها دیگر hardcode نیستند — همه از بکاند میآیند؛ هشداری نمایش داده نمیشود
|
// واحدها دیگر hardcode نیستند — همه از بکاند میآیند؛ هشداری نمایش داده نمیشود
|
||||||
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
|
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
|
||||||
@@ -275,9 +283,8 @@ export function termsheetSummary(store: Store, q: Quote): { title: string; sub:
|
|||||||
// فیلترهای دفتر
|
// فیلترهای دفتر
|
||||||
export function filterQuotes(
|
export function filterQuotes(
|
||||||
store: Store,
|
store: Store,
|
||||||
filters: { category: string; supplier: string; status: string; search: string },
|
filters: { supplier: string; status: string; search: string },
|
||||||
): Quote[] {
|
): Quote[] {
|
||||||
const catVal = filters.category.trim();
|
|
||||||
const supVal = filters.supplier.trim();
|
const supVal = filters.supplier.trim();
|
||||||
const stVal = filters.status.trim();
|
const stVal = filters.status.trim();
|
||||||
const q = filters.search.trim().toLowerCase();
|
const q = filters.search.trim().toLowerCase();
|
||||||
@@ -300,12 +307,11 @@ export function filterQuotes(
|
|||||||
if (stKey && quote.status !== stKey) return false;
|
if (stKey && quote.status !== stKey) return false;
|
||||||
|
|
||||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||||
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay =
|
const hay =
|
||||||
`${supObj.name} ${quote.project || ""} ` +
|
`${supObj.name} ${quote.project || ""} ` +
|
||||||
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||||
if (!hay.toLowerCase().includes(q)) return false;
|
if (!hay.toLowerCase().includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ export interface Supplier {
|
|||||||
// ماده/کالا
|
// ماده/کالا
|
||||||
export interface Material {
|
export interface Material {
|
||||||
id: string; // "m1", "m2", ...
|
id: string; // "m1", "m2", ...
|
||||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + مشخصه)
|
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
||||||
topic: string;
|
topic: string;
|
||||||
category: string; // اختیاری در بکاند؛ همیشه string در فرانت (default: "")
|
|
||||||
brand: string; // مثلاً "ذوب آهن"
|
brand: string; // مثلاً "ذوب آهن"
|
||||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||||
base_unit: string; // واحد پایه
|
base_unit: string; // واحد پایه
|
||||||
@@ -97,23 +96,6 @@ export interface Quote {
|
|||||||
termsheets: Termsheet[];
|
termsheets: Termsheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// واحد مرجع هر دسته
|
|
||||||
export interface CategoryUnit {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
ref_unit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ضریب تبدیل (دستهای)
|
|
||||||
export interface UnitConversion {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
brand: string; // خالی = همه برندها
|
|
||||||
from_unit: string;
|
|
||||||
to_unit: string;
|
|
||||||
factor: number; // 1 from_unit = factor * to_unit
|
|
||||||
}
|
|
||||||
|
|
||||||
// ضریب تبدیل استاندارد (جهانی)
|
// ضریب تبدیل استاندارد (جهانی)
|
||||||
export interface StandardConversion {
|
export interface StandardConversion {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -128,8 +110,6 @@ export interface Store {
|
|||||||
materials: Material[];
|
materials: Material[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
quotes: Quote[];
|
quotes: Quote[];
|
||||||
categoryUnits: CategoryUnit[];
|
|
||||||
unitConversions: UnitConversion[];
|
|
||||||
standardConversions: StandardConversion[];
|
standardConversions: StandardConversion[];
|
||||||
topics: Topic[]; // دستهبندیهای ۸گانه از /material-categories
|
topics: Topic[]; // دستهبندیهای ۸گانه از /material-categories
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
|||||||
import { AcceptModal } from "./AcceptModal";
|
import { AcceptModal } from "./AcceptModal";
|
||||||
import { EditModal } from "./EditModal";
|
import { EditModal } from "./EditModal";
|
||||||
|
|
||||||
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
@@ -28,7 +28,7 @@ export function DashboardPage() {
|
|||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const projects = useProjects();
|
const projects = useProjects();
|
||||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
const { standardConversions } = useUnits();
|
||||||
|
|
||||||
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
||||||
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
||||||
@@ -40,8 +40,8 @@ export function DashboardPage() {
|
|||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
|
||||||
const store = useMemo(
|
const store = useMemo(
|
||||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
|
() => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
|
||||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
[quotes, suppliers, materials, projects, standardConversions, topics],
|
||||||
);
|
);
|
||||||
|
|
||||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at)
|
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at)
|
||||||
@@ -80,7 +80,6 @@ export function DashboardPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
|
||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,7 +137,6 @@ export function DashboardPage() {
|
|||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
categories={categoryOptions}
|
|
||||||
suppliers={supplierOptions}
|
suppliers={supplierOptions}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||||
|
|||||||
@@ -14,17 +14,16 @@ import {
|
|||||||
useAddSupplier,
|
useAddSupplier,
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
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,
|
||||||
@@ -87,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);
|
||||||
@@ -154,7 +154,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
cat: m.category || "",
|
cat: m.name || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
qty: li.quantity,
|
qty: li.quantity,
|
||||||
@@ -190,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: [],
|
||||||
@@ -218,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,
|
||||||
@@ -233,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 () => {
|
||||||
@@ -265,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[] = [];
|
||||||
@@ -275,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";
|
||||||
@@ -294,12 +350,7 @@ 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 = materialsCopy.find(
|
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||||
(m) =>
|
|
||||||
normalize(m.category) === normalize(cat) &&
|
|
||||||
normalize(m.brand) === normalize(brand) &&
|
|
||||||
specsKey(m.specs) === specsKey(specs),
|
|
||||||
);
|
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material) {
|
||||||
materialId = material.id;
|
materialId = material.id;
|
||||||
@@ -308,7 +359,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
id: nextId("m", materialsCopy),
|
id: nextId("m", materialsCopy),
|
||||||
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
||||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||||
category: cat,
|
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit,
|
||||||
@@ -326,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);
|
||||||
@@ -363,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;
|
||||||
@@ -410,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">
|
||||||
@@ -425,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"
|
||||||
@@ -496,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">
|
||||||
@@ -540,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>
|
||||||
@@ -577,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 })}
|
||||||
@@ -601,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 className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
|
||||||
<input
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
|
||||||
placeholder="واحد سنجش"
|
|
||||||
value={row.unit}
|
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جعبه ۳ عددی مقادیر */}
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
<NumberInput
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
disabled={hasReceipts}
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
|
||||||
value={row.qty}
|
<NumberInput
|
||||||
onChange={(qty) => {
|
disabled={hasReceipts}
|
||||||
patchItem(row.key, {
|
value={row.qty}
|
||||||
qty,
|
onChange={(qty) => {
|
||||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
patchItem(row.key, {
|
||||||
});
|
qty,
|
||||||
}}
|
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||||
placeholder="تعداد"
|
});
|
||||||
/>
|
}}
|
||||||
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۴: قیمت هر واحد (تمامعرض با نمایش حروف) */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
|
||||||
|
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
<NumberInput
|
<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}
|
||||||
@@ -670,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"
|
||||||
@@ -687,44 +748,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
بازگرداندن این قلم به معامله
|
بازگرداندن این قلم به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
</div>
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
@@ -745,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 className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||||
|
<div className="text-[11.5px] text-gray-500 mt-0.5">
|
||||||
|
روشهای پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -766,70 +796,16 @@ 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>
|
|
||||||
|
|
||||||
{!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>
|
</div>
|
||||||
@@ -895,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
|
||||||
@@ -928,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>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
|||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
category: string;
|
|
||||||
supplier: string;
|
supplier: string;
|
||||||
status: string;
|
status: string;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -40,7 +39,6 @@ const STATUS_TO_KEY: Record<string, string> = {
|
|||||||
export interface FilterToolbarProps {
|
export interface FilterToolbarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
onChange: (f: Filters) => void;
|
onChange: (f: Filters) => void;
|
||||||
categories: string[];
|
|
||||||
suppliers: { id: string; name: string }[];
|
suppliers: { id: string; name: string }[];
|
||||||
sortDir: "desc" | "asc";
|
sortDir: "desc" | "asc";
|
||||||
onToggleSort: () => void;
|
onToggleSort: () => void;
|
||||||
@@ -49,7 +47,6 @@ export interface FilterToolbarProps {
|
|||||||
export function FilterToolbar({
|
export function FilterToolbar({
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
categories,
|
|
||||||
suppliers,
|
suppliers,
|
||||||
sortDir,
|
sortDir,
|
||||||
onToggleSort,
|
onToggleSort,
|
||||||
@@ -58,10 +55,10 @@ export function FilterToolbar({
|
|||||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||||
|
|
||||||
// شمارش تعداد فیلترهای فعال به جز سرچ
|
// شمارش تعداد فیلترهای فعال به جز سرچ
|
||||||
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length;
|
const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length;
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
onChange({ ...filters, supplier: "", status: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
||||||
@@ -153,18 +150,6 @@ export function FilterToolbar({
|
|||||||
|
|
||||||
{/* دراپداونها داخل کشو */}
|
{/* دراپداونها داخل کشو */}
|
||||||
<div className="flex flex-col gap-3.5">
|
<div className="flex flex-col gap-3.5">
|
||||||
<div className="field">
|
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
|
|
||||||
<Combobox
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
|
||||||
items={categories}
|
|
||||||
allowAdd={false}
|
|
||||||
placeholder="همه دستهها"
|
|
||||||
onInputChange={(v) => set({ category: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">تأمینکننده</label>
|
<label className="mb-1 block text-xs font-semibold text-muted">تأمینکننده</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -204,17 +189,6 @@ export function FilterToolbar({
|
|||||||
|
|
||||||
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
|
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
|
||||||
<div className="hidden md:flex flex-wrap items-end gap-3">
|
<div className="hidden md:flex flex-wrap items-end gap-3">
|
||||||
<div className="field min-w-[150px] flex-1">
|
|
||||||
<label>دسته</label>
|
|
||||||
<Combobox
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
|
||||||
items={categories}
|
|
||||||
allowAdd={false}
|
|
||||||
placeholder="همه"
|
|
||||||
onInputChange={(v) => set({ category: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field min-w-[170px] flex-1">
|
<div className="field min-w-[170px] flex-1">
|
||||||
<label>تأمینکننده</label>
|
<label>تأمینکننده</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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 }: 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);
|
||||||
@@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
if (q.status === "offered") {
|
if (q.status === "offered") {
|
||||||
acceptedCell = (
|
acceptedCell = (
|
||||||
<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" />
|
||||||
@@ -134,53 +136,81 @@ 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>
|
||||||
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
|
||||||
q.is_official
|
|
||||||
? "bg-green-bg text-green"
|
|
||||||
: "bg-slate-bg text-muted",
|
|
||||||
)}
|
|
||||||
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
|
||||||
>
|
|
||||||
{q.is_official ? "رسمی" : "غیررسمی"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
|
||||||
|
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||||
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||||
|
{sup.location && (
|
||||||
|
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
||||||
|
{sup.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.is_official && (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||||
|
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
|
||||||
|
<span>فاکتور رسمی</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* دکمه ویرایش */}
|
||||||
<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 +218,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 +233,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 +254,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 +264,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 +321,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
itemTermsheetIds,
|
itemTermsheetIds,
|
||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
refConverted,
|
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
@@ -76,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
||||||
const total = lineItemTotal(li);
|
const total = lineItemTotal(li);
|
||||||
const projs = itemProjects(q, li);
|
const projs = itemProjects(q, li);
|
||||||
const conv = isRejected ? null : refConverted(store, li);
|
|
||||||
|
|
||||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||||
@@ -140,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
|
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{conv && (
|
|
||||||
<div className="fb-conv">
|
|
||||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
|
||||||
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function ChangeInventoryPage() {
|
|||||||
let m = matByName(matName);
|
let m = matByName(matName);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||||||
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
|
m = { id: created.id, name: created.name, canonical_unit: null };
|
||||||
}
|
}
|
||||||
const op: Record<string, unknown> = {
|
const op: Record<string, unknown> = {
|
||||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
|
|||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
|
||||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
@@ -55,8 +55,7 @@ export function InventoryPage() {
|
|||||||
const isPM = user?.role === "pm";
|
const isPM = user?.role === "pm";
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||||
category: "",
|
|
||||||
warehouse: params.get("warehouse") || "",
|
warehouse: params.get("warehouse") || "",
|
||||||
material: "",
|
material: "",
|
||||||
project: "",
|
project: "",
|
||||||
@@ -81,7 +80,6 @@ export function InventoryPage() {
|
|||||||
inventory.forEach((r) => {
|
inventory.forEach((r) => {
|
||||||
const whId = r.warehouse?.id;
|
const whId = r.warehouse?.id;
|
||||||
if (!whId || !visibleIds.has(whId)) return;
|
if (!whId || !visibleIds.has(whId)) return;
|
||||||
if (filters.category && r.material.category?.id !== filters.category) return;
|
|
||||||
if (filters.warehouse && whId !== filters.warehouse) return;
|
if (filters.warehouse && whId !== filters.warehouse) return;
|
||||||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||||
@@ -132,7 +130,7 @@ export function InventoryPage() {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||||
|
|
||||||
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project));
|
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen" />;
|
if (loading) return <div className="min-h-screen" />;
|
||||||
if (!user || (!isAdmin && !isPM)) {
|
if (!user || (!isAdmin && !isPM)) {
|
||||||
@@ -248,40 +246,6 @@ 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">
|
||||||
{/* دستهبندیها */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
|
||||||
!filters.category
|
|
||||||
? "bg-accent text-white font-bold"
|
|
||||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
همه دستهبندیها
|
|
||||||
</button>
|
|
||||||
{lookup.categories.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
|
||||||
filters.category === c.id
|
|
||||||
? "bg-accent text-white font-bold"
|
|
||||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* پروژهها (مخصوص ادمین) */}
|
{/* پروژهها (مخصوص ادمین) */}
|
||||||
{isAdmin && lookup.projects.length > 0 && (
|
{isAdmin && lookup.projects.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -324,7 +288,7 @@ export function InventoryPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
پاک کردن فیلترها
|
پاک کردن فیلترها
|
||||||
@@ -347,7 +311,6 @@ export function InventoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
groups.map((g) => {
|
groups.map((g) => {
|
||||||
const isExp = expandedKey === g.key;
|
const isExp = expandedKey === g.key;
|
||||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
|
||||||
return (
|
return (
|
||||||
<InventoryMobileCard
|
<InventoryMobileCard
|
||||||
key={g.key}
|
key={g.key}
|
||||||
@@ -355,7 +318,6 @@ export function InventoryPage() {
|
|||||||
isExp={isExp}
|
isExp={isExp}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
matName={materialName(lookup.materials, g.matId)}
|
matName={materialName(lookup.materials, g.matId)}
|
||||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
|
||||||
wh={whName(warehouses, g.whId)}
|
wh={whName(warehouses, g.whId)}
|
||||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||||
unit={unitName(lookup.units, g.unitId)}
|
unit={unitName(lookup.units, g.unitId)}
|
||||||
@@ -410,7 +372,6 @@ export function InventoryPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{groups.map((g) => {
|
{groups.map((g) => {
|
||||||
const isExp = expandedKey === g.key;
|
const isExp = expandedKey === g.key;
|
||||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
|
||||||
return (
|
return (
|
||||||
<GroupRow
|
<GroupRow
|
||||||
key={g.key}
|
key={g.key}
|
||||||
@@ -418,7 +379,6 @@ export function InventoryPage() {
|
|||||||
isExp={isExp}
|
isExp={isExp}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
matName={materialName(lookup.materials, g.matId)}
|
matName={materialName(lookup.materials, g.matId)}
|
||||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
|
||||||
wh={whName(warehouses, g.whId)}
|
wh={whName(warehouses, g.whId)}
|
||||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||||
unit={unitName(lookup.units, g.unitId)}
|
unit={unitName(lookup.units, g.unitId)}
|
||||||
@@ -475,7 +435,6 @@ function InventoryMobileCard({
|
|||||||
isExp,
|
isExp,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
matName,
|
matName,
|
||||||
matCatName,
|
|
||||||
wh,
|
wh,
|
||||||
projName,
|
projName,
|
||||||
unit,
|
unit,
|
||||||
@@ -486,7 +445,6 @@ function InventoryMobileCard({
|
|||||||
isExp: boolean;
|
isExp: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
matName: string;
|
matName: string;
|
||||||
matCatName: string;
|
|
||||||
wh: string;
|
wh: string;
|
||||||
projName: string;
|
projName: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -508,11 +466,6 @@ function InventoryMobileCard({
|
|||||||
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||||
{matName}
|
{matName}
|
||||||
</h3>
|
</h3>
|
||||||
{matCatName !== "—" && (
|
|
||||||
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
|
||||||
{matCatName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -574,12 +527,11 @@ function InventoryMobileCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
|
||||||
g: Group;
|
g: Group;
|
||||||
isExp: boolean;
|
isExp: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
matName: string;
|
matName: string;
|
||||||
matCatName: string;
|
|
||||||
wh: string;
|
wh: string;
|
||||||
projName: string;
|
projName: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -591,7 +543,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
|
|||||||
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||||
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||||
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
|
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
|
||||||
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
|
|
||||||
<td className="py-2.5 pe-3">
|
<td className="py-2.5 pe-3">
|
||||||
<div>{wh}</div>
|
<div>{wh}</div>
|
||||||
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
|
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
|
|||||||
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
|
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
|
||||||
|
|
||||||
export interface LookupData {
|
export interface LookupData {
|
||||||
categories: { id: string; slug: string; name: string }[];
|
|
||||||
units: { id: number; code: string; name_fa: string | null }[];
|
units: { id: number; code: string; name_fa: string | null }[];
|
||||||
brands: { id: string; name: string }[];
|
brands: { id: string; name: string }[];
|
||||||
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||||
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData {
|
|||||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||||
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
||||||
const [lookup, setLookup] = useState<LookupData>({
|
const [lookup, setLookup] = useState<LookupData>({
|
||||||
categories: [], units: [], brands: [], materials: [], projects: [],
|
units: [], brands: [], materials: [], projects: [],
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||||
apiV26.lookup.categories(),
|
|
||||||
apiV26.lookup.units(),
|
apiV26.lookup.units(),
|
||||||
apiV26.lookup.brands(),
|
apiV26.lookup.brands(),
|
||||||
apiV26.lookup.materials(),
|
apiV26.lookup.materials(),
|
||||||
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
|
|||||||
apiV26.inventory.list({ project_id: projectId }),
|
apiV26.inventory.list({ project_id: projectId }),
|
||||||
apiV26.inventory.transactions({ project_id: projectId }),
|
apiV26.inventory.transactions({ project_id: projectId }),
|
||||||
]);
|
]);
|
||||||
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
|
setLookup({ units, brands, materials: mats, projects: projs });
|
||||||
setWarehouses(whs);
|
setWarehouses(whs);
|
||||||
setInventory(inv);
|
setInventory(inv);
|
||||||
setTransactions(txs);
|
setTransactions(txs);
|
||||||
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
|
|||||||
return m ? m.name : "—";
|
return m ? m.name : "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
|
|
||||||
const c = categories.find((x) => x.id === id);
|
|
||||||
return c ? c.name : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
||||||
const p = projects.find((x) => x.id === id);
|
const p = projects.find((x) => x.id === id);
|
||||||
return p ? p.name : "—";
|
return p ? p.name : "—";
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
refConverted,
|
|
||||||
supplierById,
|
supplierById,
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { faNum, fmtDate } from "../../lib/formatters";
|
import { faNum, fmtDate } from "../../lib/formatters";
|
||||||
@@ -81,11 +80,11 @@ export function ProjectsPage() {
|
|||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
const { standardConversions } = useUnits();
|
||||||
const addProject = useAddProject();
|
const addProject = useAddProject();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
|
||||||
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics };
|
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
||||||
|
|
||||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -189,17 +188,10 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
|
|
||||||
const itemLine = (li: Quote["line_items"][number]) => {
|
const itemLine = (li: Quote["line_items"][number]) => {
|
||||||
const total = lineItemTotal(li);
|
const total = lineItemTotal(li);
|
||||||
const conv = refConverted(store, li);
|
|
||||||
return (
|
return (
|
||||||
<div key={li.id}>
|
<div key={li.id}>
|
||||||
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
||||||
<b>{faNum(total || 0)} تومان</b>
|
<b>{faNum(total || 0)} تومان</b>
|
||||||
{conv && (
|
|
||||||
<span className="pj-conv">
|
|
||||||
{" "}
|
|
||||||
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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";
|
||||||
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { PurchaseHistory } from "./PurchaseHistory";
|
import { PurchaseHistory } from "./PurchaseHistory";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و 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() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
@@ -29,6 +39,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;
|
||||||
@@ -48,20 +71,8 @@ export function SupplierDetailPage() {
|
|||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}, [materials]);
|
}, [materials]);
|
||||||
|
|
||||||
// متریالهای خریداریشده از این تأمینکننده (از پیشنهادها)
|
// دستهی این تأمینکننده — دسته فقط برچسبِ خودِ تأمینکننده است (v2.7)
|
||||||
const purchased = useMemo(() => {
|
const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
|
||||||
const out = new Set<string>();
|
|
||||||
quotes.forEach((q) => {
|
|
||||||
if (q.supplier_id !== id) return;
|
|
||||||
(q.line_items || []).forEach((li) => {
|
|
||||||
if (li.rejected) return;
|
|
||||||
const m = materials.find((x) => x.id === li.material_id);
|
|
||||||
if (!m || !m.category) return;
|
|
||||||
out.add(m.category);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}, [quotes, materials, id]);
|
|
||||||
|
|
||||||
if (!s) {
|
if (!s) {
|
||||||
return (
|
return (
|
||||||
@@ -80,7 +91,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 () => {
|
||||||
@@ -128,21 +138,51 @@ 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 gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xl font-extrabold">{s.name}</div>
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">{topicName}</Badge>
|
<div className="text-xl font-extrabold">{s.name}</div>
|
||||||
|
<Badge variant="outline">{topicName}</Badge>
|
||||||
|
</div>
|
||||||
|
{avgLeadTime != null && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<span>{avgLeadTime} روز</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
|
||||||
|
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||||||
<span>
|
<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">
|
||||||
<Link
|
<Link
|
||||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||||
@@ -163,7 +203,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" /> متریالهای تأمینشده
|
||||||
@@ -203,7 +243,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>
|
||||||
@@ -220,4 +260,4 @@ export function SupplierDetailPage() {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||||
const out = new Set<string>();
|
function cleanPhoneForTel(phone?: string | null): string {
|
||||||
materials
|
if (!phone) return "";
|
||||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
const enDigits = phone
|
||||||
.forEach((m) => out.add(m.category));
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
suppliers
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||||
@@ -40,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">
|
||||||
@@ -60,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>
|
||||||
<span>
|
|
||||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||||
</span>
|
{s.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||||
|
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
|
||||||
|
title="تماس مستقیم با تأمینکننده"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
|
||||||
|
<span className="tabular-nums">{s.phone}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span>
|
<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">
|
||||||
@@ -82,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,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useSuppliers } from "../../hooks";
|
import { useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
|
||||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
|
||||||
const out = new Set<string>();
|
|
||||||
materials
|
|
||||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
|
||||||
.forEach((m) => out.add(m.category));
|
|
||||||
suppliers
|
|
||||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
|
||||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { 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";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
|
||||||
import { Combobox } from "../../components/Combobox";
|
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;
|
||||||
@@ -41,43 +43,16 @@ export function itemRowLabel(it: ItemDraft): string {
|
|||||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
|
||||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
|
|
||||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
|
||||||
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
|
||||||
// نامِ پایه باقی بماند.
|
|
||||||
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
|
||||||
const fallback = (name || "").trim();
|
|
||||||
if (!fallback) return "";
|
|
||||||
let base = fallback;
|
|
||||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
|
||||||
const parts = [brand, category, ...specs]
|
|
||||||
.map((p) => (p || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((a, b) => b.length - a.length);
|
|
||||||
for (const p of parts) {
|
|
||||||
if (!base.includes(p)) continue;
|
|
||||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
|
||||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
|
||||||
if (!next) continue;
|
|
||||||
base = next;
|
|
||||||
}
|
|
||||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
|
||||||
if (parts.some((p) => p === base)) return fallback;
|
|
||||||
return base || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
|
|
||||||
export function materialOptions(
|
export function materialOptions(
|
||||||
materials: Material[],
|
materials: Material[],
|
||||||
_topic: string,
|
_topic: string,
|
||||||
item: ItemDraft,
|
item: ItemDraft,
|
||||||
field: "cat" | "brand" | "spec",
|
field: "brand" | "spec",
|
||||||
allItems: ItemDraft[] = [],
|
allItems: ItemDraft[] = [],
|
||||||
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();
|
||||||
|
|
||||||
@@ -92,22 +67,18 @@ export function materialOptions(
|
|||||||
return [...out];
|
return [...out];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (field === "cat") {
|
|
||||||
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
|
|
||||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
if (field === "brand") {
|
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) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
if (cat)
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
list = list.filter(
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||||
|
);
|
||||||
|
|
||||||
const base = new Set<string>();
|
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)));
|
||||||
@@ -116,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();
|
||||||
|
|
||||||
@@ -135,7 +106,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
const allMaterialNames = useMemo(() => {
|
const allMaterialNames = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
materials.forEach((m) => {
|
materials.forEach((m) => {
|
||||||
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
|
const base = materialBaseName(m.name, m.brand, m.specs);
|
||||||
if (base) set.add(base);
|
if (base) set.add(base);
|
||||||
});
|
});
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
@@ -193,150 +164,205 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
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 };
|
||||||
}
|
}
|
||||||
const dup = materials.some(
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
(m) =>
|
|
||||||
normalize(m.topic) === normalize(rec.topic) &&
|
|
||||||
normalize(m.category) === normalize(rec.category) &&
|
|
||||||
normalize(m.brand) === normalize(rec.brand) &&
|
|
||||||
specsKey(m.specs) === specsKey(rec.specs),
|
|
||||||
);
|
|
||||||
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">
|
||||||
</h3>
|
<div className="flex items-center gap-2">
|
||||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
<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">
|
||||||
|
۲
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
</span>
|
||||||
<span>متریال</span>
|
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||||
<span>برند</span>
|
اقلام سفارش
|
||||||
<span>مشخصه</span>
|
</h3>
|
||||||
<span>تعداد</span>
|
</div>
|
||||||
<span>واحد</span>
|
<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">
|
||||||
<span>قیمت واحد</span>
|
{faNum(items.length)} قلم
|
||||||
<span>قیمت کل</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||||
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||||
|
</p>
|
||||||
</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) => (
|
||||||
<Combobox
|
<div
|
||||||
value={it.cat}
|
key={it.tempId}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||||
items={allMaterialNames}
|
>
|
||||||
allowAdd
|
{/* سربرگ قلم */}
|
||||||
addSuffix="به عنوان متریال جدید"
|
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||||
placeholder="متریال"
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
<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">
|
||||||
onBlur={() => rememberField("i-cat", it)}
|
{faNum(idx + 1)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
</span>
|
||||||
className="min-w-0"
|
<span className="text-xs font-bold text-ink truncate">
|
||||||
/>
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||||
<Combobox
|
</span>
|
||||||
value={it.brand}
|
</div>
|
||||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
{items.length > 1 && (
|
||||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
<button
|
||||||
allowAdd
|
type="button"
|
||||||
addSuffix="به عنوان برند جدید"
|
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"
|
||||||
placeholder="برند"
|
title="حذف قلم"
|
||||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
onClick={() => remove(it.tempId)}
|
||||||
onBlur={() => rememberField("i-brand", it)}
|
>
|
||||||
inputClassName="!text-[12px] !h-9"
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
className="min-w-0"
|
<span className="text-[11px]">حذف</span>
|
||||||
/>
|
</button>
|
||||||
<MultiSelectCombobox
|
)}
|
||||||
values={it.specs}
|
</div>
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
|
||||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
{/* ردیف ۱: متریال و برند */}
|
||||||
placeholder="مشخصه"
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
className="min-w-0"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
/>
|
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||||
<NumberInput
|
<Combobox
|
||||||
value={it.qty}
|
value={it.cat}
|
||||||
onChange={(qty) => {
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
patch(it.tempId, {
|
items={allMaterialNames}
|
||||||
qty,
|
allowAdd
|
||||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
addSuffix="به عنوان متریال جدید"
|
||||||
});
|
placeholder="نام متریال"
|
||||||
}}
|
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||||
placeholder="تعداد"
|
onBlur={() => rememberField("i-cat", it)}
|
||||||
showWords
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
/>
|
className="min-w-0"
|
||||||
<Combobox
|
/>
|
||||||
value={it.unit}
|
</div>
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
allowAdd
|
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||||
addSuffix="به عنوان واحد جدید"
|
<Combobox
|
||||||
placeholder="واحد"
|
value={it.brand}
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||||
onAdd={(v) => {
|
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||||
const name = v.trim();
|
allowAdd
|
||||||
if (!name) return;
|
addSuffix="به عنوان برند جدید"
|
||||||
void apiV26.catalog
|
placeholder="برند"
|
||||||
.createUnit(name)
|
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||||
.then(() => {
|
onBlur={() => rememberField("i-brand", it)}
|
||||||
setUnitOptions((prev) =>
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
className="min-w-0"
|
||||||
);
|
/>
|
||||||
})
|
</div>
|
||||||
.catch(() => {});
|
</div>
|
||||||
}}
|
|
||||||
inputClassName="!text-[12px] !h-9"
|
{/* ردیف ۲: مشخصه فنی */}
|
||||||
className="min-w-0"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
/>
|
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||||
<NumberInput
|
<MultiSelectCombobox
|
||||||
value={it.price}
|
values={it.specs}
|
||||||
onChange={(price) => {
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
patch(it.tempId, {
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||||
price,
|
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
className="min-w-0"
|
||||||
});
|
/>
|
||||||
}}
|
</div>
|
||||||
placeholder="قیمت واحد"
|
|
||||||
showWords
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
maxDecimals={2}
|
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||||
/>
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
<NumberInput
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
value={it.total}
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
onChange={(total) => {
|
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||||
patch(it.tempId, {
|
<NumberInput
|
||||||
total,
|
value={it.qty}
|
||||||
price:
|
onChange={(qty) => {
|
||||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
patch(it.tempId, {
|
||||||
});
|
qty,
|
||||||
}}
|
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||||
placeholder="قیمت کل"
|
});
|
||||||
showWords
|
}}
|
||||||
/>
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
/>
|
||||||
<button
|
</div>
|
||||||
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"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
title="حذف قلم"
|
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||||
onClick={() => remove(it.tempId)}
|
<Combobox
|
||||||
>
|
value={it.unit}
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
<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" />
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
</svg>
|
allowAdd
|
||||||
</button>
|
addSuffix="به عنوان واحد جدید"
|
||||||
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
|
onAdd={(v) => {
|
||||||
|
const name = v.trim();
|
||||||
|
if (!name) return;
|
||||||
|
void apiV26.catalog
|
||||||
|
.createUnit(name)
|
||||||
|
.then(() => {
|
||||||
|
setUnitOptions((prev) =>
|
||||||
|
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}}
|
||||||
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۴: قیمت هر واحد (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||||
|
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.price}
|
||||||
|
onChange={(price) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
price,
|
||||||
|
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت هر واحد"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۵: قیمت کل قلم (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.total}
|
||||||
|
onChange={(total) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
total,
|
||||||
|
price:
|
||||||
|
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت کل قلم"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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,43 +9,61 @@ 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
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||||
|
!isCurrent && "hidden md:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{label}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ import { topicByName } from "../../lib/topics";
|
|||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
import { matchMaterial } from "../../lib/quoteUtils";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { Stepper } from "./Stepper";
|
import { Stepper } from "./Stepper";
|
||||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||||
@@ -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" };
|
||||||
@@ -293,20 +282,13 @@ export function WizardPage() {
|
|||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
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) {
|
||||||
material = materials.find(
|
const matched = matchMaterial(
|
||||||
(m) =>
|
materials.filter((m) => looksLikeUuid(m.id)),
|
||||||
looksLikeUuid(m.id) &&
|
cat,
|
||||||
normalize(m.category) === normalize(cat) &&
|
brand,
|
||||||
normalize(m.brand) === normalize(brand) &&
|
specs,
|
||||||
specsKey(m.specs) === specsKey(specs),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛
|
|
||||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
|
||||||
if (!material && cat) {
|
|
||||||
material = materials.find(
|
|
||||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
|
||||||
);
|
);
|
||||||
|
if (matched) material = matched;
|
||||||
}
|
}
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material && looksLikeUuid(material.id)) {
|
if (material && looksLikeUuid(material.id)) {
|
||||||
@@ -317,7 +299,6 @@ export function WizardPage() {
|
|||||||
id: nextId("m", materials),
|
id: nextId("m", materials),
|
||||||
name: wantName,
|
name: wantName,
|
||||||
topic: selectedTopic,
|
topic: selectedTopic,
|
||||||
category: cat,
|
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit || "عدد",
|
base_unit: unit || "عدد",
|
||||||
@@ -430,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="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
@@ -438,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) => {
|
||||||
@@ -465,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}
|
||||||
@@ -481,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}
|
||||||
@@ -495,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}
|
||||||
@@ -510,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>
|
||||||
@@ -526,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" />
|
||||||
@@ -551,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" />
|
||||||
@@ -560,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);
|
||||||
@@ -601,7 +581,7 @@ export function WizardPage() {
|
|||||||
goToStep(emptyFields[0]?.step ?? 1);
|
goToStep(emptyFields[0]?.step ?? 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
تکمیل فیلد ها
|
تکمیل فیلدها
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type {
|
import type {
|
||||||
CategoryUnit,
|
|
||||||
Material,
|
Material,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
@@ -15,7 +14,6 @@ import type {
|
|||||||
Store,
|
Store,
|
||||||
Supplier,
|
Supplier,
|
||||||
Topic,
|
Topic,
|
||||||
UnitConversion,
|
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
|
|
||||||
interface AppStore {
|
interface AppStore {
|
||||||
@@ -24,8 +22,6 @@ interface AppStore {
|
|||||||
materials: Material[];
|
materials: Material[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
quotes: Quote[];
|
quotes: Quote[];
|
||||||
categoryUnits: CategoryUnit[];
|
|
||||||
unitConversions: UnitConversion[];
|
|
||||||
standardConversions: StandardConversion[];
|
standardConversions: StandardConversion[];
|
||||||
topics: Topic[]; // دستهبندیهای ۸گانه — از /material-categories
|
topics: Topic[]; // دستهبندیهای ۸گانه — از /material-categories
|
||||||
|
|
||||||
@@ -45,9 +41,6 @@ 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;
|
||||||
setCategoryUnit: (cu: CategoryUnit) => void;
|
|
||||||
setConversion: (c: UnitConversion) => void;
|
|
||||||
deleteConversion: (id: string) => void;
|
|
||||||
setStandardConversion: (sc: StandardConversion) => void;
|
setStandardConversion: (sc: StandardConversion) => void;
|
||||||
deleteStandardConversion: (id: string) => void;
|
deleteStandardConversion: (id: string) => void;
|
||||||
|
|
||||||
@@ -62,8 +55,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
|||||||
materials: [],
|
materials: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
quotes: [],
|
quotes: [],
|
||||||
categoryUnits: [],
|
|
||||||
unitConversions: [],
|
|
||||||
standardConversions: [],
|
standardConversions: [],
|
||||||
topics: [],
|
topics: [],
|
||||||
|
|
||||||
@@ -77,8 +68,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
|||||||
materials: store.materials,
|
materials: store.materials,
|
||||||
projects: store.projects,
|
projects: store.projects,
|
||||||
quotes: store.quotes,
|
quotes: store.quotes,
|
||||||
categoryUnits: store.categoryUnits,
|
|
||||||
unitConversions: store.unitConversions,
|
|
||||||
standardConversions: store.standardConversions,
|
standardConversions: store.standardConversions,
|
||||||
topics: store.topics,
|
topics: store.topics,
|
||||||
}),
|
}),
|
||||||
@@ -96,24 +85,6 @@ 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] })),
|
||||||
setCategoryUnit: (cu) =>
|
|
||||||
set((s) => {
|
|
||||||
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
|
|
||||||
const categoryUnits = [...s.categoryUnits];
|
|
||||||
if (idx >= 0) categoryUnits[idx] = cu;
|
|
||||||
else categoryUnits.push(cu);
|
|
||||||
return { categoryUnits };
|
|
||||||
}),
|
|
||||||
setConversion: (c) =>
|
|
||||||
set((s) => {
|
|
||||||
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
|
|
||||||
const unitConversions = [...s.unitConversions];
|
|
||||||
if (idx >= 0) unitConversions[idx] = c;
|
|
||||||
else unitConversions.push(c);
|
|
||||||
return { unitConversions };
|
|
||||||
}),
|
|
||||||
deleteConversion: (id) =>
|
|
||||||
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
|
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user