10 Commits
Author SHA1 Message Date
Behrad cff39542b0 Update ERP configuration and line item handling 2026-09-28 00:28:34 +03:30
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

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

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
Behrad 49dd159fb1 feat: improve line items layout, number inputs and supplier click-to-call 2026-09-22 23:06:17 +03:30
Behrad 972b1b49cb fix(dashboard): restore expand toggle on mobile quote cards and add explicit expand action bar 2026-09-21 19:57:15 +03:30
Behrad b176f0f5ff fix(wizard): optimize stepper and line items layout for mobile 2026-09-21 19:39:12 +03:30
Behrad 9386d4846f feat: add lead time badge, refine mobile supplier click, and integrate termsheet cards in edit modal 2026-09-21 19:26:52 +03:30
28 changed files with 795 additions and 3001 deletions
-45
View File
@@ -432,48 +432,3 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
for stmt in indexes: for stmt in indexes:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("audit_log table ensured") logger.info("audit_log table ensured")
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS project_transfers (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
specs_hash TEXT NOT NULL DEFAULT 'default',
note TEXT,
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
transferred_by TEXT,
received_at TIMESTAMPTZ,
received_by TEXT,
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
receipt_allocations JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
"""
CREATE TRIGGER trg_project_transfers_updated_at
BEFORE UPDATE ON project_transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("project_transfers table ensured")
-2
View File
@@ -31,7 +31,6 @@ from app.db.init_db import (
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
ensure_quote_projects, ensure_quote_projects,
ensure_project_transfers_table,
ensure_v26, ensure_v26,
) )
from app.observability import RequestContextMiddleware, setup_logging from app.observability import RequestContextMiddleware, setup_logging
@@ -108,7 +107,6 @@ async def lifespan(_app: FastAPI):
await ensure_v26(engine) await ensure_v26(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine) await ensure_drop_category_tables(engine)
await ensure_project_transfers_table(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
-3
View File
@@ -32,8 +32,6 @@ from app.models.procurement import (
TermsheetScheduleStep, TermsheetScheduleStep,
) )
from app.models.project_transfer import ProjectTransfer
__all__ = [ __all__ = [
"Base", "Base",
"Unit", "Unit",
@@ -62,5 +60,4 @@ __all__ = [
"ItemDecision", "ItemDecision",
"ItemReceipt", "ItemReceipt",
"QuoteAttachment", "QuoteAttachment",
"ProjectTransfer",
] ]
-87
View File
@@ -1,87 +0,0 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
DateTime,
ForeignKey,
Numeric,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
class ProjectTransfer(Base):
"""project_transfers — Inter-project (inter-site) material transfer ledger.
Records the movement of materials from a source project warehouse to a
destination project. Stock is deducted from the source warehouse immediately
upon creation (status = 'in_transit') via out_transaction_id.
When the destination project PM confirms receipt in 'Arrivals', stock is
added to the destination warehouse (status = 'received').
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
Once 'received', the transfer is permanently locked from edit or cancellation.
"""
__tablename__ = "project_transfers"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
to_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
)
transfer_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
transferred_by: Mapped[str | None] = mapped_column(Text)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
received_by: Mapped[str | None] = mapped_column(Text)
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("warehouses.id", ondelete="SET NULL")
)
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
)
-2
View File
@@ -14,7 +14,6 @@ from app.routers import (
material_categories, material_categories,
materials, materials,
pm, pm,
project_transfers,
projects, projects,
quote_events, quote_events,
quotes, quotes,
@@ -51,6 +50,5 @@ ROUTERS = [
standard_conversions.router, standard_conversions.router,
inventory.router, inventory.router,
pm.router, pm.router,
project_transfers.router,
users.router, users.router,
] ]
-206
View File
@@ -1,206 +0,0 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.routers.pm import ArrivalScope, arrivals_scope
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferOut,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services import project_transfer_service
from app.utils.errors import ForbiddenError
router = APIRouter(tags=["project-transfers"])
def _to_transfer_out(t) -> dict:
return {
"id": t.id,
"from_project": (
{"id": t.from_project.id, "name": t.from_project.name}
if t.from_project
else {"id": t.from_project_id, "name": ""}
),
"from_warehouse": (
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
if t.from_warehouse
else {"id": t.from_warehouse_id, "name": ""}
),
"to_project": (
{"id": t.to_project.id, "name": t.to_project.name}
if t.to_project
else {"id": t.to_project_id, "name": ""}
),
"to_warehouse": (
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
if t.to_warehouse
else None
),
"material": (
{"id": t.material.id, "name": t.material.name}
if t.material
else {"id": t.material_id, "name": ""}
),
"brand": (
{"id": t.brand.id, "name": t.brand.name}
if t.brand
else None
),
"unit": (
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
if t.unit
else {"id": t.unit_id, "code": "", "name_fa": None}
),
"quantity": t.quantity,
"specs": t.specs or {},
"specs_hash": t.specs_hash,
"note": t.note,
"status": t.status,
"transfer_date": t.transfer_date,
"transferred_by": t.transferred_by,
"received_at": t.received_at,
"received_by": t.received_by,
"receipt_allocations": t.receipt_allocations,
"created_at": t.created_at,
"updated_at": t.updated_at,
}
# ---------------------------------------------------------------------------
# POST /inventory/project-transfers — Create inter-project transfer
# ---------------------------------------------------------------------------
@router.post("/inventory/project-transfers", status_code=201)
async def create_transfer(
payload: ProjectTransferCreate,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.create_project_transfer(
db=db,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
transferred_by=ctx.display_name,
)
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# GET /inventory/project-transfers — List transfers with direction and filters
# ---------------------------------------------------------------------------
@router.get("/inventory/project-transfers")
async def list_transfers(
project_id: uuid.UUID | None = Query(default=None),
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
status: str | None = Query(default=None),
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
target_pid = project_id or ctx.project_id
transfers = await project_transfer_service.list_project_transfers(
db=db,
project_id=target_pid,
direction=direction,
status=status,
)
return {"data": [_to_transfer_out(t) for t in transfers]}
# ---------------------------------------------------------------------------
# GET /inventory/project-transfers/{id} — Single transfer details
# ---------------------------------------------------------------------------
@router.get("/inventory/project-transfers/{transfer_id}")
async def get_transfer(
transfer_id: uuid.UUID,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
if ctx.role == "pm" and ctx.project_id is not None:
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
# ---------------------------------------------------------------------------
@router.patch("/inventory/project-transfers/{transfer_id}")
async def update_transfer(
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.update_project_transfer(
db=db,
transfer_id=transfer_id,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
)
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
# ---------------------------------------------------------------------------
@router.delete("/inventory/project-transfers/{transfer_id}")
async def delete_transfer(
transfer_id: uuid.UUID,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
res = await project_transfer_service.delete_project_transfer(
db=db,
transfer_id=transfer_id,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
)
return {"data": res}
# ---------------------------------------------------------------------------
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
# ---------------------------------------------------------------------------
@router.get("/pm/transfers/on-the-way")
async def pm_incoming_transfers(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید")
transfers = await project_transfer_service.list_project_transfers(
db=db,
project_id=ctx.project_id,
direction="incoming",
status="in_transit",
)
return {"data": [_to_transfer_out(t) for t in transfers]}
# ---------------------------------------------------------------------------
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
# ---------------------------------------------------------------------------
@router.post("/inventory/project-transfers/{transfer_id}/receive")
async def receive_transfer(
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.receive_project_transfer(
db=db,
transfer_id=transfer_id,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
received_by=ctx.display_name,
)
return {"data": _to_transfer_out(transfer)}
-6
View File
@@ -135,11 +135,6 @@ class AcceptTermsheetIn(APIModel):
schedule_steps: list[ScheduleStepIn] = Field(min_length=1) schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ProjectAllocationIn(APIModel):
project_id: UUID
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
class ItemDecisionIn(APIModel): class ItemDecisionIn(APIModel):
line_item_id: UUID line_item_id: UUID
status: Literal["chosen", "rejected"] status: Literal["chosen", "rejected"]
@@ -147,7 +142,6 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0) adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)") project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel): class ItemProjectsIn(APIModel):
-74
View File
@@ -1,74 +0,0 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
class WarehouseAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class ProjectTransferCreate(APIModel):
from_warehouse_id: UUID
to_project_id: UUID
material_id: UUID
brand_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
specs: dict = Field(default_factory=dict)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferUpdate(APIModel):
to_project_id: UUID | None = None
quantity: Decimal | None = Field(default=None, gt=0)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferReceive(APIModel):
allocations: list[WarehouseAllocationIn] = Field(
min_length=1,
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
)
received_at: datetime | None = None
note: str | None = None
class IdNameOut(APIModel):
id: UUID
name: str
class UnitBriefOut(APIModel):
id: int
code: str
name_fa: str | None = None
class ProjectTransferOut(APIModel):
id: UUID
from_project: IdNameOut
from_warehouse: IdNameOut
to_project: IdNameOut
to_warehouse: IdNameOut | None = None
material: IdNameOut
brand: IdNameOut | None = None
unit: UnitBriefOut
quantity: Decimal
specs: dict
specs_hash: str
note: str | None = None
status: str
transfer_date: datetime
transferred_by: str | None = None
received_at: datetime | None = None
received_by: str | None = None
receipt_allocations: list[dict] | None = None
created_at: datetime
updated_at: datetime
@@ -1,471 +0,0 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.catalog import Brand, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import Project
from app.models.project_transfer import ProjectTransfer
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services.inventory_service import (
_find_inventory,
_get_or_create_inventory,
canonical_specs_hash,
)
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
stmt = (
select(ProjectTransfer)
.where(ProjectTransfer.id == transfer_id)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
transfer = await db.scalar(stmt)
if transfer is None:
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
return transfer
async def create_project_transfer(
db: AsyncSession,
payload: ProjectTransferCreate,
caller_project_id: uuid.UUID | None,
caller_role: str,
transferred_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
# Validate source warehouse
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
if from_wh is None:
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
# RBAC: PM can only send from their own project
if caller_role == "pm" and caller_project_id is not None:
if from_wh.project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
from_proj = await db.get(Project, from_wh.project_id)
to_proj = await db.get(Project, payload.to_project_id)
if to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if from_wh.project_id == payload.to_project_id:
raise ValidationError(
"پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد (از انتقال بین‌انباری استفاده کنید)",
field="to_project_id",
)
# Validate material, brand, unit
mat = await db.get(Material, payload.material_id)
if mat is None:
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
if payload.brand_id is not None:
brand = await db.get(Brand, payload.brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
unit = await db.get(Unit, payload.unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
# Requirement 2: Canonical specs hash from inventory_service
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
# Check stock in source warehouse with SELECT ... FOR UPDATE
source_item = await _find_inventory(
db,
payload.from_warehouse_id,
payload.material_id,
payload.brand_id,
specs_hash,
for_update=True,
)
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
if source_item is None:
candidates_res = await db.execute(
select(InventoryItem)
.where(
InventoryItem.warehouse_id == payload.from_warehouse_id,
InventoryItem.material_id == payload.material_id,
)
.with_for_update()
)
candidates = candidates_res.scalars().all()
if len(candidates) == 1:
source_item = candidates[0]
elif len(candidates) > 1:
best_cand = None
best_score = -1
p_keys = set(specs.keys())
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
for cand in candidates:
score = 0
if cand.brand_id == payload.brand_id:
score += 10
elif cand.brand_id is None and payload.brand_id is None:
score += 10
elif cand.brand_id is None:
score += 4
c_specs = cand.specs or {}
c_keys = set(c_specs.keys())
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
for pk in p_keys:
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
score += 5
for pv in p_vals:
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
score += 5
if cand.quantity >= payload.quantity:
score += 2
if score > best_score:
best_score = score
best_cand = cand
source_item = best_cand
if source_item is None or source_item.quantity < payload.quantity:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
field="quantity",
)
# Deduct from source warehouse immediately
source_item.quantity -= payload.quantity
transfer_pair_id = uuid.uuid4()
now_dt = payload.transfer_date or datetime.now(timezone.utc)
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
if payload.note:
note_text += f" — {payload.note}"
out_tx = InventoryTransaction(
inventory_id=source_item.id,
warehouse_id=from_wh.id,
transaction_type="TRANSFER_OUT",
quantity=-payload.quantity,
transfer_pair_id=transfer_pair_id,
performed_by=transferred_by,
transaction_date=now_dt,
note=note_text,
)
db.add(out_tx)
await db.flush()
actual_brand_id = source_item.brand_id
actual_specs = source_item.specs if source_item.specs is not None else specs
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
transfer = ProjectTransfer(
from_project_id=from_wh.project_id,
from_warehouse_id=from_wh.id,
to_project_id=payload.to_project_id,
material_id=payload.material_id,
brand_id=actual_brand_id,
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
quantity=payload.quantity,
specs=actual_specs,
specs_hash=actual_specs_hash,
note=payload.note,
status="in_transit",
out_transaction_id=out_tx.id,
transfer_date=now_dt,
transferred_by=transferred_by,
)
db.add(transfer)
await db.flush()
return await get_project_transfer_or_404(db, transfer.id)
async def update_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به ویرایش انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
)
if transfer.status != "in_transit":
raise ConflictError("فقط انتقال‌های در راه (in_transit) قابل ویرایش هستند")
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
new_to_proj = await db.get(Project, payload.to_project_id)
if new_to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if payload.to_project_id == transfer.from_project_id:
raise ValidationError("پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد", field="to_project_id")
transfer.to_project_id = payload.to_project_id
# Quantity adjustment (reconciles source stock difference)
if payload.quantity is not None and payload.quantity != transfer.quantity:
delta = payload.quantity - transfer.quantity
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if delta > 0:
if source_item is None or source_item.quantity < delta:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
field="quantity",
)
source_item.quantity -= delta
else:
if source_item is not None:
source_item.quantity += abs(delta)
transfer.quantity = payload.quantity
# Update out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.quantity = -payload.quantity
if payload.note is not None:
transfer.note = payload.note
if payload.transfer_date is not None:
transfer.transfer_date = payload.transfer_date
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.transaction_date = payload.transfer_date
return await get_project_transfer_or_404(db, transfer_id)
async def delete_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> dict:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به لغو انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
)
# Reversibility (Requirement 4): Restore stock to source warehouse
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if source_item is not None:
source_item.quantity += transfer.quantity
# Delete out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
await db.delete(out_tx)
await db.delete(transfer)
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
async def receive_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
caller_project_id: uuid.UUID | None,
caller_role: str,
received_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.to_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
# Lock Guard
if transfer.status == "received":
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
if transfer.status != "in_transit":
raise ConflictError("این انتقال در وضعیت در راه نیست")
# Requirement 1: 100% full delivery logic (no partial receipt)
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
if alloc_sum != transfer.quantity:
raise ValidationError(
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
field="allocations",
)
# Validate destination warehouses belong to destination project
wh_ids = [a.warehouse_id for a in payload.allocations]
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
wh_map = {w.id: w for w in wh_rows}
for a in payload.allocations:
wh = wh_map.get(a.warehouse_id)
if wh is None or wh.project_id != transfer.to_project_id:
raise ValidationError(
"انبار انتخاب‌شده معتبر نیست یا متعلق به پروژه مقصد نمی‌باشد",
field="allocations.warehouse_id",
)
from_proj = await db.get(Project, transfer.from_project_id)
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
out_tx = (
await db.get(InventoryTransaction, transfer.out_transaction_id)
if transfer.out_transaction_id
else None
)
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
now_dt = payload.received_at or datetime.now(timezone.utc)
# Add stock to destination warehouse(s) via TRANSFER_IN
for a in payload.allocations:
dest_item = await _get_or_create_inventory(
db,
a.warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.unit_id,
transfer.specs,
transfer.specs_hash,
)
dest_item.quantity += a.quantity
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
if payload.note:
note_text += f" — {payload.note}"
in_tx = InventoryTransaction(
inventory_id=dest_item.id,
warehouse_id=a.warehouse_id,
transaction_type="TRANSFER_IN",
quantity=a.quantity,
transfer_pair_id=pair_id,
performed_by=received_by,
transaction_date=now_dt,
note=note_text,
)
db.add(in_tx)
transfer.status = "received"
transfer.received_at = now_dt
transfer.received_by = received_by
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
transfer.receipt_allocations = [
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
for a in payload.allocations
]
return await get_project_transfer_or_404(db, transfer_id)
async def list_project_transfers(
db: AsyncSession,
project_id: uuid.UUID | None = None,
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
status: str | None = None,
) -> list[ProjectTransfer]:
stmt = (
select(ProjectTransfer)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
if project_id is not None:
if direction == "outgoing":
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
elif direction == "incoming":
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
else:
stmt = stmt.where(
(ProjectTransfer.from_project_id == project_id)
| (ProjectTransfer.to_project_id == project_id)
)
if status:
stmt = stmt.where(ProjectTransfer.status == status)
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
res = await db.execute(stmt)
return list(res.scalars().all())
-197
View File
@@ -7,7 +7,6 @@ same transaction.
import asyncio import asyncio
import logging import logging
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID from uuid import UUID
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, select, update
@@ -19,7 +18,6 @@ from app.models.procurement import (
ItemTermsheet, ItemTermsheet,
LineItem, LineItem,
LineItemProjects, LineItemProjects,
LineItemSpec,
Project, Project,
Quote, Quote,
QuoteEvent, QuoteEvent,
@@ -420,78 +418,6 @@ async def replace_quote_children(
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
exists = await db.scalar(
select(QuoteProjects).where(
QuoteProjects.quote_id == quote_id,
QuoteProjects.project_id == project_id,
)
)
if not exists:
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
"""Consolidate split line items when reverting a quote back to offered.
Line items sharing the exact same material, brand_material, unit, notes,
and identical specs are merged into a single consolidated row whose quantity
is the sum of all split rows. Secondary split rows are safely deleted.
"""
line_items = (
await db.execute(
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
)
).scalars().all()
if len(line_items) <= 1:
return
# Load all specs for these line items to compare canonical signatures
specs_rows = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
)
).scalars().all()
specs_by_li: dict[UUID, list[tuple]] = {}
for sp in specs_rows:
sig = (
str(sp.attribute_definition_id),
str(sp.value_number) if sp.value_number is not None else "",
(sp.value_string or "").strip(),
str(sp.value_boolean) if sp.value_boolean is not None else "",
str(sp.value_date) if sp.value_date is not None else "",
)
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
# Group line items by identity
groups: dict[tuple, list[LineItem]] = {}
for li in line_items:
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
key = (
li.material_id,
li.brand_material_id,
li.unit_id,
(li.notes or "").strip(),
specs_sig,
)
groups.setdefault(key, []).append(li)
for key, items in groups.items():
if len(items) > 1:
primary = items[0]
total_qty = sum(Decimal(str(it.quantity)) for it in items)
primary.quantity = total_qty
secondary_ids = [it.id for it in items[1:]]
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
await db.flush()
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None: async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered.""" """Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status current = quote.status
@@ -528,8 +454,6 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
) )
.execution_options(synchronize_session=False) .execution_options(synchronize_session=False)
) )
# Requirement 2: Merge split line items back into single rows
await _merge_split_line_items_on_reopen(db, quote.id)
# LineItemProjects (candidate projects) are preserved untouched! # LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None quote.arrived_date = None
@@ -633,125 +557,6 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions" "برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
) )
termsheet_id = ts_ids_by_index[index] termsheet_id = ts_ids_by_index[index]
li_row = await db.scalar(
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
)
if li_row is None:
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
allocations = decision_in.allocations or []
if allocations:
# Requirement 3: Validate no duplicate projects within allocations of this item
alloc_pids = [a.project_id for a in allocations]
if len(alloc_pids) != len(set(alloc_pids)):
raise ValidationError(
"پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است",
field="item_decisions.allocations",
)
# Requirement 5: Decimal precision check for sum of allocation quantities
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
target_qty = Decimal(str(li_row.quantity))
if alloc_sum != target_qty:
raise ValidationError(
f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
field="item_decisions.allocations",
)
# Validate all projects exist
for a in allocations:
if await db.get(Project, a.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
if len(allocations) >= 2:
# 1. Update the original line item to the first allocation quantity
first_alloc = allocations[0]
li_row.quantity = first_alloc.quantity
decision_in.project_id = first_alloc.project_id
# Query all existing item_termsheets and line_item_specs for copying
orig_termsheets = (
await db.execute(
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
)
).scalars().all()
orig_specs = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
)
).scalars().all()
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
for split_alloc in allocations[1:]:
new_li = LineItem(
quote_id=quote.id,
material_id=li_row.material_id,
brand_material_id=li_row.brand_material_id,
unit_id=li_row.unit_id,
quantity=split_alloc.quantity,
notes=li_row.notes,
)
db.add(new_li)
await db.flush()
# Copy LineItemSpec
for sp in orig_specs:
db.add(
LineItemSpec(
line_item_id=new_li.id,
attribute_definition_id=sp.attribute_definition_id,
value_number=sp.value_number,
value_string=sp.value_string,
value_boolean=sp.value_boolean,
value_date=sp.value_date,
unit_id_at_time=sp.unit_id_at_time,
)
)
# Requirement 1: Copy ALL payment options (ItemTermsheet)
chosen_new_it: ItemTermsheet | None = None
for it_orig in orig_termsheets:
price = (
decision_in.adjusted_unit_price
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
else it_orig.unit_price
)
copied_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=it_orig.termsheet_id,
unit_price=price,
min_order_qty=it_orig.min_order_qty,
)
db.add(copied_it)
if it_orig.termsheet_id == termsheet_id:
chosen_new_it = copied_it
if chosen_new_it is None:
chosen_new_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(chosen_new_it)
await db.flush()
# Create ItemDecision for the split line item
new_decision = ItemDecision(
line_item_id=new_li.id,
status="chosen",
chosen_item_termsheet_id=chosen_new_it.id,
adjusted_unit_price=decision_in.adjusted_unit_price,
adjusted_notes=decision_in.adjusted_notes,
project_id=split_alloc.project_id,
decided_at=utcnow(),
)
db.add(new_decision)
# Requirement 4: sync QuoteProjects & LineItemProjects
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
elif len(allocations) == 1:
decision_in.project_id = allocations[0].project_id
it = await db.scalar( it = await db.scalar(
select(ItemTermsheet).where( select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id, ItemTermsheet.line_item_id == decision_in.line_item_id,
@@ -805,8 +610,6 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None: if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None: if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id") raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id) chosen_ts_ids.add(termsheet_id)
else: # rejected else: # rejected
if decision is None: if decision is None:
-1
View File
@@ -128,7 +128,6 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [ USER_PLAN = [
# (casdoor username, first, last, role) # (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"), ("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"), ("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"), ("mohaghegh", "محمد", "محقق", "pm"),
+27 -106
View File
@@ -6,8 +6,6 @@ services:
environment: environment:
POSTGRES_DB: procurement POSTGRES_DB: procurement
POSTGRES_USER: postgres POSTGRES_USER: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
@@ -15,7 +13,7 @@ services:
networks: networks:
- default - default
- shared # monitoring stack scrapes it (postgres-exporter) - shared
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
@@ -31,15 +29,7 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 1g mem_limit: 1g
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
# App is served on http://localhost (port 80) instead of :3000.
# Production: see docker-compose.prod.yml (real domains + HTTPS via
# Let's Encrypt, HTTP→HTTPS redirect).
# ==========================================================================
traefik: traefik:
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
# ("Error response from daemon" — Docker API version mismatch).
image: traefik:v3.6 image: traefik:v3.6
container_name: procurement-traefik container_name: procurement-traefik
restart: unless-stopped restart: unless-stopped
@@ -47,8 +37,6 @@ services:
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
# Containers are on several networks; Traefik must use the dedicated
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network - --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml - --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
@@ -62,8 +50,6 @@ services:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
# NOTE: mounted read-write — Docker Desktop rejects read-only
# /var/run/docker.sock mounts ("Error response from daemon").
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro - ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs - traefik_certs:/certs
@@ -81,24 +67,21 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports: ports:
- "9000:9000" # S3 API - "9000:9000"
- "9001:9001" # Web console - "9001:9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
labels: labels:
- traefik.enable=true - "traefik.enable=true"
# S3 API - "traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)"
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`) - "traefik.http.routers.minio-api.entrypoints=web"
- traefik.http.routers.minio-api.entrypoints=web - "traefik.http.services.minio-api-svc.loadbalancer.server.port=9000"
- traefik.http.routers.minio-api.service=minio-api-svc - "traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)"
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000 - "traefik.http.routers.minio-console.entrypoints=web"
# Web console - "traefik.http.routers.minio-console.service=minio-console-svc"
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`) - "traefik.http.services.minio-console-svc.loadbalancer.server.port=9001"
- traefik.http.routers.minio-console.entrypoints=web
- traefik.http.routers.minio-console.service=minio-console-svc
- traefik.http.services.minio-console-svc.loadbalancer.server.port=9001
networks: networks:
- traefik_network - traefik_network
@@ -107,39 +90,20 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 512m mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init: minio-init:
image: minio/mc:latest image: minio/mc:latest
container_name: procurement-minio-init container_name: procurement-minio-init
depends_on: depends_on:
minio: minio:
condition: service_started condition: service_started
environment: environment:
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files MINIO_BUCKET: procurement-files
entrypoint: ["/bin/sh", "-c", "until mc alias set local http://$${MINIO_ENDPOINT}$${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do echo 'waiting for minio server...'; sleep 2; done; mc mb --ignore-existing local/$${MINIO_BUCKET}; echo bucket ready;"]
entrypoint: >
/bin/sh -c "
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
echo 'waiting for minio server...'; sleep 2;
done;
mc mb --ignore-existing local/$${MINIO_BUCKET};
echo bucket $${MINIO_BUCKET} ready;
"
restart: "no" restart: "no"
# ==========================================================================
# Scheduled logical backups of BOTH databases (procurement + casdoor).
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
# ==========================================================================
db-backup: db-backup:
image: postgres:18 image: postgres:18
container_name: procurement-db-backup container_name: procurement-db-backup
@@ -152,33 +116,9 @@ services:
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14} BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes: volumes:
- ${BACKUP_DIR:-./backups}:/backups - ${BACKUP_DIR:-./backups}:/backups
entrypoint: > entrypoint: ["/bin/sh", "-c", "while true; do STAMP=$$(date +%Y%m%d-%H%M%S); pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump; pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump; find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; sleep $${BACKUP_INTERVAL_SECONDS}; done"]
/bin/sh -c "
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
while true; do
STAMP=$$(date +%Y%m%d-%H%M%S);
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
sleep $${BACKUP_INTERVAL_SECONDS};
done
"
restart: unless-stopped restart: unless-stopped
mem_limit: 256m mem_limit: 256m
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# is created by backend/tools/seed_casdoor.py — run it once after the
# first boot:
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
#
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
# created by sql/02-casdoor-db.sql on a fresh volume).
# ==========================================================================
casdoor: casdoor:
image: casbin/casdoor:latest image: casbin/casdoor:latest
container_name: procurement-casdoor container_name: procurement-casdoor
@@ -195,10 +135,10 @@ services:
- "8080:8000" - "8080:8000"
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`) - "traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)"
- traefik.http.routers.casdoor.entrypoints=web - "traefik.http.routers.casdoor.entrypoints=web"
- traefik.http.services.casdoor.loadbalancer.server.port=8000 - "traefik.http.services.casdoor.loadbalancer.server.port=8000"
networks: networks:
- traefik_network - traefik_network
@@ -220,14 +160,9 @@ services:
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false" PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev} PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly). PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173,http://192.168.1.104}
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080} PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
@@ -235,10 +170,8 @@ services:
PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123} PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text} PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-} PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-} PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
@@ -251,7 +184,7 @@ services:
networks: networks:
- traefik_network - traefik_network
- default - default
- shared # monitoring stack scrapes api:8000/metrics - shared
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""] test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
@@ -261,10 +194,10 @@ services:
start_period: 40s start_period: 40s
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`) - "traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)"
- traefik.http.routers.api.entrypoints=web - "traefik.http.routers.api.entrypoints=web"
- traefik.http.services.api.loadbalancer.server.port=8000 - "traefik.http.services.api.loadbalancer.server.port=8000"
depends_on: depends_on:
postgres: postgres:
@@ -279,27 +212,19 @@ services:
build: build:
context: ./frontend context: ./frontend
args: args:
# Developer Mode panel on the login page — dev-only aid. Production
# builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-} VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-} VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-} VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"] entrypoint: ["nginx", "-g", "daemon off;"]
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) - "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
- traefik.http.routers.frontend.entrypoints=web - "traefik.http.routers.frontend.entrypoints=web"
- traefik.http.services.frontend.loadbalancer.server.port=80 - "traefik.http.services.frontend.loadbalancer.server.port=80"
networks: networks:
- traefik_network - traefik_network
@@ -312,13 +237,9 @@ services:
mem_limit: 128m mem_limit: 128m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network: traefik_network:
name: traefik_network name: traefik_network
driver: bridge driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared: shared:
name: erp_shared name: erp_shared
driver: bridge driver: bridge
+4 -4
View File
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return ( return (
<div className="flex items-start gap-3 w-full my-2"> <div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ */} {/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-36 shrink-0"> <div className="w-28 shrink-0">
<JalaliDatePicker <JalaliDatePicker
value={milestone.when_iso} value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
/> />
</div> </div>
{/* کادر ورود مبلغ و حروف زیر آن */} {/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<NumberInput <NumberInput
value={milestone.amount} value={milestone.amount}
+24 -20
View File
@@ -7,24 +7,26 @@ export interface NumberInputProps {
onChange: (n: number | null) => void; onChange: (n: number | null) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
showWords?: boolean; // نمایش معادل فارسی زیر input inputClassName?: string;
align?: "right" | "center" | "left";
showWords?: boolean;
inputMode?: "numeric" | "decimal" | undefined; inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد) maxDecimals?: number;
disabled?: boolean; disabled?: boolean;
} }
// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی
export function NumberInput({ export function NumberInput({
value, value,
onChange, onChange,
placeholder, placeholder,
className, className,
inputClassName,
align = "right",
showWords = false, showWords = false,
inputMode = "numeric", inputMode = "numeric",
maxDecimals, maxDecimals,
disabled = false, disabled = false,
}: NumberInputProps) { }: NumberInputProps) {
// فرمت با رعایت حداکثر اعشار (گرد کردن)
const formatValue = (n: number): string => { const formatValue = (n: number): string => {
let v = n; let v = n;
if (maxDecimals != null) { if (maxDecimals != null) {
@@ -36,8 +38,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 +60,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-xs font-normal text-ink shadow-xs transition-colors placeholder:text-xs placeholder:font-normal placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
inputMode={inputMode} inputMode={inputMode}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw); const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw); const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) { if (parsed != null && !trailingSep) {
@@ -85,16 +94,11 @@ export function NumberInput({
}} }}
onBlur={() => commit(text)} onBlur={() => commit(text)}
/> />
{showWords && ( {showWords && n != null && (
<span className={cn("num-pill", n == null && "empty")}> <div className="mt-1 block text-right text-[11px] text-muted">
{n != null ? ( <span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<> <span className="text-muted">({toWords(n)})</span>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span> </div>
</>
) : (
""
)}
</span>
)} )}
</div> </div>
); );
+9 -79
View File
@@ -30,10 +30,6 @@ import type {
Supplier, Supplier,
TransactionType, TransactionType,
Warehouse, Warehouse,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferUpdateIn,
ProjectTransferReceiveIn,
} from "./types"; } from "./types";
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
@@ -243,32 +239,20 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
async function getData<T>(path: string): Promise<T> { async function getData<T>(path: string): Promise<T> {
const res = await request<any>(path); const res = await request<{ data: T }>(path);
if (res && typeof res === "object" && "data" in res) { return res.data;
return res.data as T;
}
return res as T;
} }
async function postData<T>(path: string, body: unknown): Promise<T> { async function postData<T>(path: string, body: unknown): Promise<T> {
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
if (res && typeof res === "object" && "data" in res) { return res.data;
return res.data as T;
}
return res as T;
} }
async function patchData<T>(path: string, body: unknown): Promise<T> { async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
if (res && typeof res === "object" && "data" in res) { return res.data;
return res.data as T;
}
return res as T;
} }
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<any>(path, { method: "DELETE" }); const res = await request<{ data: T }>(path, { method: "DELETE" });
if (res && typeof res === "object" && "data" in res) { return res.data;
return res.data as T;
}
return res as T;
} }
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر // آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
@@ -943,30 +927,15 @@ export const api = {
if (becomingAccepted) { if (becomingAccepted) {
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
// پروژه‌ی واحدِ انتخاب‌شده یا تخصیص‌های چندپروژه‌ای برای هر قلم // پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id)
const targetProjByLine = new Map<string, string>(); const targetProjByLine = new Map<string, string>();
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
for (const li of patch.line_items || []) { for (const li of patch.line_items || []) {
if (li.allocations && li.allocations.length > 0) {
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
for (const a of li.allocations) {
const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
if (pid && a.quantity > 0) {
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
}
}
if (resolvedAllocs.length) {
allocationsByLine.set(li.id, resolvedAllocs);
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
}
} else {
const tp = li.target_project; const tp = li.target_project;
if (tp && tp.name) { if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid); if (pid) targetProjByLine.set(li.id, pid);
} }
} }
}
const payload = { const payload = {
accepted_termsheets: acceptedTs.map((t) => ({ accepted_termsheets: acceptedTs.map((t) => ({
@@ -984,13 +953,11 @@ export const api = {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0; const chosen = !li.rejected && idx >= 0;
const chosenProjId = targetProjByLine.get(li.id); const chosenProjId = targetProjByLine.get(li.id);
const lineAllocs = allocationsByLine.get(li.id);
return { return {
line_item_id: li.id, line_item_id: li.id,
status: chosen ? "chosen" : "rejected", status: chosen ? "chosen" : "rejected",
...(chosen ? { accepted_termsheet_index: idx } : {}), ...(chosen ? { accepted_termsheet_index: idx } : {}),
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}), ...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
}; };
}), }),
@@ -1762,41 +1729,4 @@ export const apiV26 = {
attributes: [], attributes: [],
}), }),
}, },
projectTransfers: {
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
const q = new URLSearchParams();
if (projectId) q.set("project_id", projectId);
if (direction) q.set("direction", direction);
if (status) q.set("status", status);
const qs = q.toString() ? `?${q.toString()}` : "";
try {
const res = await getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
return Array.isArray(res) ? res : [];
} catch (err) {
console.error("Failed to fetch project transfers:", err);
return [];
}
},
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
try {
const res = await getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`);
return Array.isArray(res) ? res : [];
} catch (err) {
console.error("Failed to fetch on-the-way transfers:", err);
return [];
}
},
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
},
}; };
-50
View File
@@ -73,7 +73,6 @@ export interface LineItem {
projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects) projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects)
target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id) target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id)
target_project_id?: string | null; target_project_id?: string | null;
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژه‌ای در پذیرش
} }
// پیشنهاد خرید (اصلی‌ترین entity) // پیشنهاد خرید (اصلی‌ترین entity)
@@ -200,52 +199,3 @@ export interface ConfirmedReceipt {
quality_note: string | null; quality_note: string | null;
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
} }
export interface ProjectTransfer {
id: string;
from_project: { id: string; name: string };
from_warehouse: { id: string; name: string };
to_project: { id: string; name: string };
to_warehouse?: { id: string; name: string } | null;
material: { id: string; name: string };
brand?: { id: string; name: string } | null;
unit: { id: number; code: string; name_fa?: string | null };
quantity: number;
specs: Record<string, string>;
specs_hash: string;
note?: string | null;
status: "in_transit" | "received" | "cancelled";
transfer_date: string;
transferred_by?: string | null;
received_at?: string | null;
received_by?: string | null;
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
created_at: string;
updated_at: string;
}
export interface ProjectTransferCreateIn {
from_warehouse_id: string;
to_project_id: string;
material_id: string;
brand_id?: string | null;
unit_id: number;
quantity: number;
specs?: Record<string, string>;
note?: string | null;
transfer_date?: string | null;
}
export interface ProjectTransferUpdateIn {
to_project_id?: string;
quantity?: number;
note?: string | null;
transfer_date?: string | null;
}
export interface ProjectTransferReceiveIn {
allocations: { warehouse_id: string; quantity: number }[];
received_at?: string | null;
note?: string | null;
}
+35 -407
View File
@@ -23,134 +23,12 @@ import {
import { MilestoneRow } from "../../components/MilestoneRow"; import { MilestoneRow } from "../../components/MilestoneRow";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
const TEENS = [
"ده",
"یازده",
"دوازده",
"سیزده",
"چهارده",
"پانزده",
"شانزده",
"هفده",
"هجده",
"نوزده",
];
const TENS = [
"",
"",
"بیست",
"سی",
"چهل",
"پنجاه",
"شصت",
"هفتاد",
"هشتاد",
"نود",
];
const HUNDREDS = [
"",
"صد",
"دویست",
"سیصد",
"چهارصد",
"پانصد",
"ششصد",
"هفتصد",
"هشتصد",
"نهصد",
];
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
const DECIMAL_SCALES: Record<number, string> = {
1: "دهم",
2: "صدم",
3: "هزارم",
4: "ده‌هزارم",
};
function convertThreeDigits(n: number): string {
if (n === 0) return "";
const parts: string[] = [];
const h = Math.floor(n / 100);
const rem = n % 100;
if (h > 0) parts.push(HUNDREDS[h]);
if (rem >= 10 && rem <= 19) {
parts.push(TEENS[rem - 10]);
} else {
const t = Math.floor(rem / 10);
const u = rem % 10;
if (t > 0) parts.push(TENS[t]);
if (u > 0) parts.push(ONES[u]);
}
return parts.join(" و ");
}
function toEnglishDigits(str: string): string {
return str
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
}
function numberToPersianWords(val: number | string | null | undefined): string {
if (val == null || val === "") return "";
const numStr = toEnglishDigits(String(val).trim());
const num = parseFloat(numStr);
if (isNaN(num)) return "";
if (num === 0) return "صفر";
const isNeg = num < 0;
const parts = Math.abs(num).toString().split(".");
const intStr = parts[0];
const decStr = parts[1];
let intNum = parseInt(intStr, 10);
let intResult = "";
if (intNum === 0) {
intResult = "صفر";
} else {
const chunks: number[] = [];
while (intNum > 0) {
chunks.push(intNum % 1000);
intNum = Math.floor(intNum / 1000);
}
const chunkWords: string[] = [];
for (let i = chunks.length - 1; i >= 0; i--) {
const c = chunks[i];
if (c > 0) {
const w = convertThreeDigits(c);
const s = SCALES[i];
chunkWords.push(s ? `${w} ${s}` : w);
}
}
intResult = chunkWords.join(" و ");
}
if (decStr && parseInt(decStr, 10) > 0) {
const cleanDecStr = decStr.slice(0, 4);
const decNum = parseInt(cleanDecStr, 10);
const decWord = numberToPersianWords(decNum);
const scale = DECIMAL_SCALES[cleanDecStr.length];
if (scale) {
if (intNum === 0) {
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
}
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
}
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
}
return isNeg ? `منفی ${intResult}` : intResult;
}
interface CardDraft { interface CardDraft {
domId: string; domId: string;
isNew: boolean; isNew: boolean;
milestones: Milestone[]; milestones: Milestone[];
label: string | null; label: string | null; // null = پیش‌فرض «گزینه N»
} }
export interface AcceptModalProps { export interface AcceptModalProps {
@@ -173,9 +51,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set()); const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({}); const [assignments, setAssignments] = useState<Record<string, string>>({});
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({}); const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
const [itemAllocations, setItemAllocations] = useState<
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
>({});
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString()); const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -192,35 +67,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(rem); setRemovedItems(rem);
setAssignments({}); setAssignments({});
const projSel: Record<string, string> = {}; const projSel: Record<string, string> = {};
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
q.line_items.forEach((li) => { q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean); const names = (li.projects || []).map((p) => p.name).filter(Boolean);
const defaultProj = li.target_project?.name projSel[li.id] = li.target_project?.name
? li.target_project.name ? li.target_project.name
: names.length : names.length
? names[0] ? names[0]
: q.projects && q.projects.length : q.projects && q.projects.length
? q.projects[0] ? q.projects[0]
: q.project || ""; : q.project || "";
projSel[li.id] = defaultProj;
if (li.allocations && li.allocations.length) {
allocs[li.id] = li.allocations.map((a, idx) => ({
rowId: `alloc-${li.id}-${idx}`,
project_name: a.project_name,
quantity: a.quantity,
}));
} else {
allocs[li.id] = [
{
rowId: `alloc-${li.id}-0`,
project_name: defaultProj,
quantity: li.quantity ?? 1,
},
];
}
}); });
setItemProjectsSel(projSel); setItemProjectsSel(projSel);
setItemAllocations(allocs);
setReady(true); setReady(true);
}, [q]); }, [q]);
@@ -280,44 +137,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setAssignments((a) => ({ ...a, [liId]: domId })); setAssignments((a) => ({ ...a, [liId]: domId }));
}; };
const addAllocationRow = (liId: string, totalQty: number) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
const newRow = {
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
project_name: firstAvailable,
quantity: remaining > 0 ? remaining : "",
};
return { ...prev, [liId]: [...currentList, newRow] };
});
};
const removeAllocationRow = (liId: string, rowId: string) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
if (currentList.length <= 1) return prev;
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
});
};
const updateAllocationRow = (
liId: string,
rowId: string,
patch: Partial<{ project_name: string; quantity: number | string }>,
) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
return {
...prev,
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
};
});
};
const confirm = () => { const confirm = () => {
if (!q) return; if (!q) return;
@@ -343,7 +162,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true; if (removedItems.has(li.id)) li.rejected = true;
}); });
const active = activeItems(next); const active = activeItems(next);
if (!active.length) { if (!active.length) {
toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است."); toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
@@ -388,60 +206,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
}); });
for (const li of active) { // پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
const allocs = itemAllocations[li.id] || []; // li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
const m = materialById(materials, li.material_id);
const title = formatMaterialTitle(m);
const targetQty = Number(li.quantity ?? 0);
if (!allocs.length) {
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
return;
}
for (const a of allocs) {
if (!a.project_name.trim()) {
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیف‌های تخصیص را مشخص کنید.`);
return;
}
const qVal = parseFloat(String(a.quantity));
if (isNaN(qVal) || qVal <= 0) {
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگ‌تر از صفر باشد.`);
return;
}
}
const pNames = allocs.map((a) => a.project_name.trim());
if (new Set(pNames).size !== pNames.length) {
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیف‌های تخصیص انتخاب شده است.`);
return;
}
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
const diff = Math.abs(sumQty - targetQty);
if (diff > 0.0001) {
toast.error(
`مجموع مقادیر تخصیص‌یافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
);
return;
}
}
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.rejected) return; if (li.rejected) return;
const allocs = itemAllocations[li.id];
if (allocs && allocs.length) {
li.allocations = allocs.map((a) => ({
project_name: a.project_name.trim(),
quantity: parseFloat(String(a.quantity)) || 0,
}));
li.target_project = { id: "", name: allocs[0].project_name.trim() };
} else {
const name = itemProjectsSel[li.id]; const name = itemProjectsSel[li.id];
if (name !== undefined) { if (name === undefined) return;
li.target_project = name ? { id: "", name } : null; li.target_project = name ? { id: "", name } : null;
}
}
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -461,7 +232,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl"> <DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
<div className="flex flex-col max-h-[85vh] w-full"> <div className="flex flex-col max-h-[85vh] w-full">
{/* هدر ثابت */} {/* هدر ثابت و هم‌تراز */}
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right"> <div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right"> <DialogHeader className="p-0 text-right">
<DialogTitle className="text-right text-lg font-bold text-slate-800"> <DialogTitle className="text-right text-lg font-bold text-slate-800">
@@ -572,196 +343,53 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div> </div>
</section> </section>
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */} {/* بخش ۳: تخصیص اقلام به پروژه */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="flex items-center justify-between mb-1"> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
</div>
<div className="mb-4 text-[12px] text-muted"> <div className="mb-4 text-[12px] text-muted">
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید. برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{projectOptions.length === 0 ? (
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید.
</div>
) : (
<div className="flex flex-col gap-4">
{allItems {allItems
.filter((li) => !removedItems.has(li.id)) .filter((li) => !removedItems.has(li.id))
.map((li) => { .map((li) => {
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
const targetQty = Number(li.quantity ?? 0);
const allocs = itemAllocations[li.id] || [];
const allocSum = allocs.reduce(
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
0
);
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
const isComplete = Math.abs(diff) < 0.0001;
const isOver = diff < -0.0001;
return ( return (
<div <div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
key={li.id} <div className="mb-3 flex items-center justify-between">
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30" <span className="text-[13px] font-semibold text-slate-800 truncate">
>
{/* هدر کارت قلم */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary shrink-0" />
<span className="text-[13.5px] font-bold text-slate-800">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</span> </span>
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit}
</span>
</div> </div>
{projectOptions.length ? (
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
کل: {fmtNum(targetQty)} {li.unit}
</span>
{isComplete ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
<CheckCircle2 className="w-3 h-3" />
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
</span>
) : isOver ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
<AlertCircle className="w-3 h-3" />
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
<AlertCircle className="w-3 h-3" />
باقیمانده: {fmtNum(diff)} {li.unit}
</span>
)}
</div>
</div>
{/* ردیف‌های تخصیص — تراز افقی یکپارچه بدون جابجایی دکمه‌ها */}
<div className="flex flex-col gap-2.5 mb-3">
{allocs.map((alloc) => {
const otherUsedProjects = new Set(
allocs
.filter((a) => a.rowId !== alloc.rowId)
.map((a) => a.project_name.trim())
.filter(Boolean)
);
const words = numberToPersianWords(alloc.quantity);
return (
<div
key={alloc.rowId}
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
>
{/* انتخاب پروژه و دکمه حذف موبایل */}
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
<div className="flex-1 min-w-0">
<select <select
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700" className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
value={alloc.project_name} value={itemProjectsSel[li.id] ?? ""}
onChange={(e) => onChange={(e) =>
updateAllocationRow(li.id, alloc.rowId, { setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
project_name: e.target.value,
})
} }
> >
<option value="">انتخاب پروژه...</option> {(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
{projectOptions.map((p) => { <option key={i} value="">
const isUsed = otherUsedProjects.has(p); انتخاب پروژه...
return (
<option key={p} value={p} disabled={isUsed}>
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
</option> </option>
); ))}
})} {projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select> </select>
</div> ) : (
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
{/* دکمه حذف موبایل */}
<button
type="button"
disabled={allocs.length <= 1}
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
title="حذف این ردیف تخصیص"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
<div className="flex flex-col w-full sm:w-44 shrink-0">
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
<input
type="number"
step="any"
min="0"
placeholder="مقدار"
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
style={{ MozAppearance: "textfield" }}
dir="ltr"
value={alloc.quantity}
onChange={(e) =>
updateAllocationRow(li.id, alloc.rowId, {
quantity: e.target.value,
})
}
/>
<div
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
title={`واحد: ${li.unit}`}
>
{li.unit}
</div>
</div>
{words ? (
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
{words} {li.unit}
</div>
) : null}
</div>
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپ‌داون و اینپوت */}
<button
type="button"
disabled={allocs.length <= 1}
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
title="حذف این ردیف تخصیص"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
{/* فوتر کارت قلم */}
<div className="flex items-center justify-between pt-1">
<button
type="button"
onClick={() => addAllocationRow(li.id, targetQty)}
disabled={
allocs.length >= projectOptions.length && projectOptions.length > 0
}
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
</button>
<span className="text-[11px] text-muted">
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
</span>
</div>
</div>
);
})}
</div>
)} )}
</div>
);
})}
</div>
</section> </section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} {/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
+157 -177
View File
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { MilestoneRow } from "../../components/MilestoneRow"; import { TermsheetCard } from "../../components/TermsheetCard";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]); const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]); const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]); const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null); const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return { return {
key: li.id, key: li.id,
origLi: li, origLi: li,
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
// دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست)
cat: m.name || "", cat: m.name || "",
brand: m.brand || "", brand: m.brand || "",
specs: materialSpecs(m), specs: materialSpecs(m),
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice") .list(quoteId, "invoice")
.then((list) => alive && setInvoices(list)) .then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([])); .catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => { return () => {
alive = false; alive = false;
}; };
}, [quoteId]); }, [quoteId]);
if (!q || !ready) return null; const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const tsLabel = (card: EditTsCard, idx: number) => { const getCoveredIds = (card: EditTsCard) => {
const orig = q.termsheets.find((t) => t.id === card.domId); const cardId = card.domId || card.key;
return orig?.label || `گزینه ${faNum(idx + 1)}`; const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
}; };
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const patchItem = (key: string, patch: Partial<EditItemRow>) => { const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
}; };
const addItemRow = () => { const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [ setItemRows((rows) => [
...rows, ...rows,
{ {
key: "new-" + Math.random().toString(36).slice(2, 9), key: newKey,
cat: "", cat: "",
brand: "", brand: "",
specs: [], specs: [],
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "", unit: "",
price: null, price: null,
total: null, total: null,
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [], tsDomIds: defaultTsId ? [defaultTsId] : [],
acceptedTsId: null, acceptedTsId: null,
rejected: false, rejected: false,
restore: true, restore: true,
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const addTsCard = () => { const addTsCard = () => {
setTsCards((cards) => [ setTsCards((cards) => [
...cards, ...cards,
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, {
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
]); ]);
}; };
const removeTsCard = (key: string) => { const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key)); setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
}; };
const confirm = async () => { const confirm = async () => {
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects]; next.projects = [...selectedProjects];
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
? domId ? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId; tsIdMap[domId] = realId;
tsIdMap[card.key] = realId;
const orig = next.termsheets.find((t) => t.id === realId); const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`; const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered"; const st = orig?.status || "offered";
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const total = row.total; const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
// هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد)
let material = matchMaterial(materialsCopy, cat, brand, specs); let material = matchMaterial(materialsCopy, cat, brand, specs);
let materialId: string; let materialId: string;
if (material) { if (material) {
@@ -322,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!rejected) { if (!rejected) {
row.tsDomIds.forEach((domId) => { row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId; const realId = tsIdMap[domId] || domId;
if (realId) termsheet_ids.push(realId); if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
}); });
if (!termsheet_ids.length && next.termsheets.length) { if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id); termsheet_ids.push(next.termsheets[0].id);
@@ -359,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const referencedTs = new Set<string>(); const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null; li.accepted_ts_id = null;
@@ -384,26 +438,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
li.accepted_ts_id = null; li.accepted_ts_id = null;
li.target_project = null; li.target_project = null;
li.target_project_id = null; li.target_project_id = null;
li.allocations = undefined;
}); });
// ادغام سطرهای تفکیک‌شده حاصل از تخصیص چندپروژه‌ای (الزام ۲)
const mergedMap = new Map<string, LineItem>();
for (const li of next.line_items) {
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
const existing = mergedMap.get(key);
if (existing) {
const q1 = Number(existing.quantity ?? 0);
const q2 = Number(li.quantity ?? 0);
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
if (existing.unit_price != null && existing.quantity != null) {
existing.total_price = existing.unit_price * existing.quantity;
}
} else {
mergedMap.set(key, { ...li });
}
}
next.line_items = Array.from(mergedMap.values());
} else if (next.status === "accepted") { } else if (next.status === "accepted") {
next.arrived_date = null; next.arrived_date = null;
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
@@ -425,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">
@@ -440,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"
@@ -511,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">
@@ -555,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>
@@ -592,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 })}
@@ -616,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2.5"> {/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label> <label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox <MultiSelectCombobox
disabled={hasReceipts} disabled={hasReceipts}
values={row.specs} values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })} onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions} options={specOptions}
placeholder="مشخصه" placeholder="انتخاب مشخصات..."
className="min-w-0"
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* بخش مقادیر و محاسبات مالی */}
<label className="text-[11px] font-semibold text-gray-600">واحد</label> <div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
<input {/* ردیف ۳: تعداد + واحد سنجش */}
disabled={hasReceipts} <div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" <div className="flex flex-col gap-1 min-w-0">
placeholder="واحد سنجش" <label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div>
{/* جعبه ۳ عددی مقادیر */}
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.qty} value={row.qty}
onChange={(qty) => { onChange={(qty) => {
patchItem(row.key, { patchItem(row.key, {
qty, qty,
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total, total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
}); });
}} }}
placeholder="تعداد" placeholder="مقدار"
className="!text-right text-right"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label> <label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد (شاخه، متر، کیلو...)"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.price} value={row.price}
onChange={(price) => { onChange={(price) => {
patchItem(row.key, { patchItem(row.key, {
price, price,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total, total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
}); });
}} }}
placeholder="واحد" placeholder="قیمت هر واحد"
showWords
maxDecimals={2} maxDecimals={2}
className="!text-right text-right"
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label> <div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.total} value={row.total}
@@ -685,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"
@@ -702,46 +748,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/> />
بازگرداندن این قلم به معامله بازگرداندن این قلم به معامله
</label> </label>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, cIdx) => {
const isSelected = row.tsDomIds.includes(card.domId || card.key);
return (
<label
key={card.key}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
isSelected
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
hasReceipts && "cursor-not-allowed opacity-60",
)}
>
<input
type="checkbox"
disabled={hasReceipts}
className="accent-blue-600 h-3.5 w-3.5 rounded"
checked={isSelected}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
<span>{tsLabel(card, cIdx)}</span>
</label>
);
})}
</div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
))} ))}
{!hasReceipts && ( {!hasReceipts && (
@@ -760,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
)} )}
{/* تب ۲: شرایط پرداخت */} {/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
{activeTab === "terms" && ( {activeTab === "terms" && (
<div className="space-y-3.5"> <div className="space-y-3.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div> <div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
<div className="text-[11.5px] text-gray-500 mt-0.5">
روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
</div>
</div>
{!hasReceipts && ( {!hasReceipts && (
<Button <Button
type="button" type="button"
@@ -781,72 +796,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tsCards.map((card, idx) => ( {tsCards.map((card, idx) => (
<div <TermsheetCard
key={card.key} key={card.key}
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs" index={idx + 1}
> milestones={card.milestones}
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5"> onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
<div className="flex items-center gap-1.5"> items={itemOptions}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600"> coveredIds={getCoveredIds(card)}
{faNum(idx + 1)} onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
</span> onRemove={() => removeTsCard(card.key)}
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
<span className="text-[11px] text-gray-500 font-medium">
({faNum(card.milestones.length)} مرحله)
</span>
</div>
{!hasReceipts && tsCards.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف گزینه"
onClick={() => removeTsCard(card.key)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="flex flex-col gap-2 mb-3">
{card.milestones.map((ms, i) => (
<MilestoneRow
key={i}
milestone={ms}
disabled={hasReceipts}
onChange={(nextMs) =>
updateTs(card.key, {
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
}
/> />
))} ))}
</div> </div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="w-full sm:w-auto text-xs text-gray-700"
onClick={() =>
updateTs(card.key, {
milestones: [...card.milestones, { when_iso: null, amount: null }],
})
}
>
<Plus className="h-3.5 w-3.5 me-1" />
افزودن مرحله پرداخت
</Button>
)}
</div>
))}
</div>
</div> </div>
)} )}
@@ -910,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
@@ -943,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>
+80 -31
View File
@@ -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>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */} </div>
<span
className={cn( {/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4", <div className="mt-0.5 flex min-w-0 items-center gap-1.5">
q.is_official {sup.location && (
? "bg-green-bg text-green" <span className="truncate text-[10px] text-muted sm:text-[11px]">
: "bg-slate-bg text-muted", {sup.location}
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span> </span>
)}
{q.is_official && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
<span>فاکتور رسمی</span>
</span>
)}
</div> </div>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{fulfillmentBadge ?? ( {fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs"> <Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]} {STATUS_LABEL[q.status]}
</Badge> </Badge>
)} )}
{/* دکمه ویرایش */}
<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>
); );
} }
+12 -219
View File
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks"; import { refreshProcurementStore } from "../../hooks";
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types"; import type { PendingArrival, Warehouse } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید) // کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
@@ -25,31 +25,24 @@ export function ArrivalsPage() {
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const [items, setItems] = useState<PendingArrival[]>([]); const [items, setItems] = useState<PendingArrival[]>([]);
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null); // quoteId const [saving, setSaving] = useState<string | null>(null); // quoteId
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({}); const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const activeProject = isPM ? user?.project_id || undefined : undefined; const activeProject = isPM ? user?.project_id || undefined : undefined;
const [arr, whs, incoming] = await Promise.all([ const [arr, whs] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
apiV26.pm.onTheWay(activeProject), apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject), apiV26.warehouses.list(activeProject),
apiV26.projectTransfers.onTheWay(activeProject),
]); ]);
setItems(Array.isArray(arr) ? arr : []); setItems(arr);
setWarehouses(Array.isArray(whs) ? whs : []); setWarehouses(whs);
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
setItems([]);
setIncomingTransfers([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -219,53 +212,6 @@ export function ArrivalsPage() {
} }
}; };
// ---------- transfer helpers ----------
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
return transferDrafts[t.id];
}
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
};
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
};
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
const targetQty = parseFloat(String(transfer.quantity));
if (allocSum !== targetQty) {
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
return;
}
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
toast.error("لطفاً برای تمامی ردیف‌های دارای مقدار، انبار مقصد را انتخاب کنید.");
return;
}
setTransferSaving(transfer.id);
try {
const payloadAllocs = allocs
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
await apiV26.projectTransfers.receive(transfer.id, {
allocations: payloadAllocs,
}, activeProject);
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
} finally {
setTransferSaving(null);
}
};
if (!isAdmin && !isPM) { if (!isAdmin && !isPM) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -287,171 +233,19 @@ export function ArrivalsPage() {
/> />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted"> <div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند. 🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان
وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از
مانده‌ی سفارش بیشتر نشود.
</div> </div>
{loading ? ( {loading ? (
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div> <div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
) : !(orders || []).length && !(incomingTransfers || []).length ? ( ) : !orders.length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." /> <EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
) : ( ) : (
<> orders.map(([quoteId, its]) => {
{/* بخش انتقال‌های بین کارگاهی در راه */}
{(incomingTransfers || []).length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقال‌های بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها</span>
</div>
{(incomingTransfers || []).map((t) => {
const draftAllocs = transferDraftOf(t);
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
const targetQty = parseFloat(String(t.quantity));
const isExactMatch = allocSum === targetQty;
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
const days = daysElapsed(t.transfer_date);
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
return (
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
{/* سربرگ انتقال */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
<Badge variant="offered">در راه</Badge>
{days !== null && (
<Badge variant={days >= 7 ? "offered" : "muted"}>
روز گذشته: {faNum(days)} روز
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
<span>ارسال‌کننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
</div>
</div>
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[13px] font-bold">{t.material?.name}</span>
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
<Badge variant="outline">
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
</Badge>
{t.specs && Object.keys(t.specs).length > 0 && (
<span className="text-[11.5px] text-muted">
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
</span>
)}
</div>
{t.note && (
<div className="mt-1 text-[11.5px] text-muted">
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
</div>
)}
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
<div className="text-[12px] font-semibold text-muted mb-2">
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
</div>
<div className="flex flex-col gap-2">
{draftAllocs.map((a) => (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<input
type="number"
inputMode="decimal"
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="تعداد"
value={a.qty}
onChange={(e) => {
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
setTransferAlloc(t.id, next);
}}
/>
<select
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
value={a.whId}
onChange={(e) => {
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
setTransferAlloc(t.id, next);
}}
>
<option value="">— انتخاب انبار پروژه مقصد —</option>
{destWhOptions.map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
{draftAllocs.length > 1 && (
<button
type="button"
className="h-8 px-2 text-muted hover:text-red transition-colors"
onClick={() => {
const next = draftAllocs.filter((x) => x.key !== a.key);
setTransferAlloc(t.id, next);
}}
title="حذف این ردیف تخصیص"
>
✕
</button>
)}
</div>
))}
<button
type="button"
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
onClick={() => {
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
setTransferAlloc(t.id, next);
}}
>
+ افزودن انبار دیگر برای تقسیم بار
</button>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
<div>
{isExactMatch ? (
<span className="font-semibold text-emerald-600">
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
</span>
) : (
<span className="font-bold text-rose-600">
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
</span>
)}
{hasEmptyWh && (
<span className="ms-2 font-bold text-rose-600">
— لطفاً انبار مقصد را انتخاب کنید
</span>
)}
</div>
<Button
size="sm"
variant="green"
disabled={!isValid || transferSaving === t.id}
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
>
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
</Button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* بخش سفارش‌های خرید در راه */}
{orders.map(([quoteId, its]) => {
const first = its[0]; const first = its[0];
const open = expanded.has(quoteId); const open = expanded.has(quoteId);
const hasSubmittable = orderHasSubmittable(quoteId); const hasSubmittable = orderHasSubmittable(quoteId);
@@ -607,8 +401,7 @@ export function ArrivalsPage() {
)} )}
</div> </div>
); );
})} })
</>
)} )}
</PageLayout> </PageLayout>
</div> </div>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
@@ -6,22 +6,21 @@ import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { api, apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { notifyDataChanged } from "../../lib/crossTab"; import { notifyDataChanged } from "../../lib/crossTab";
import { useInventoryData, unitName, whName } from "./useInventoryData"; import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { uniqueUnits } from "../../lib/units"; import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } from "../../lib/types"; import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
type FormType = TransactionType | "TRANSFER" | "PROJECT_TRANSFER"; type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." }, { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال می‌شود تا مدیر پروژه مقصد آن را تحویل بگیرد." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." }, { t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
@@ -54,22 +53,6 @@ export function ChangeInventoryPage() {
const [type, setType] = useState<FormType>("PURCHASE"); const [type, setType] = useState<FormType>("PURCHASE");
const [whId, setWhId] = useState(""); const [whId, setWhId] = useState("");
const [toProjectId, setToProjectId] = useState("");
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [transfers, setTransfers] = useState<ProjectTransfer[]>([]);
const [loadingTransfers, setLoadingTransfers] = useState(false);
// Edit modal state
const [editingTransfer, setEditingTransfer] = useState<ProjectTransfer | null>(null);
const [editQty, setEditQty] = useState("");
const [editToProjectId, setEditToProjectId] = useState("");
const [editNote, setEditNote] = useState("");
const [editBusy, setEditBusy] = useState(false);
// Delete modal state
const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState<ProjectTransfer | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [fromId, setFromId] = useState(""); const [fromId, setFromId] = useState("");
const [toId, setToId] = useState(""); const [toId, setToId] = useState("");
const [matTxt, setMatTxt] = useState(""); const [matTxt, setMatTxt] = useState("");
@@ -211,82 +194,6 @@ export function ChangeInventoryPage() {
} }
}; };
const loadTransfers = useCallback(async () => {
if (!user?.project_id) return;
setLoadingTransfers(true);
try {
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
setTransfers(Array.isArray(list) ? list : []);
} catch (err) {
console.error("Failed to load project transfers:", err);
setTransfers([]);
} finally {
setLoadingTransfers(false);
}
}, [user?.project_id]);
useEffect(() => {
api.projects
.list()
.then((res) => setAllProjects(Array.isArray(res) ? res : []))
.catch(() => setAllProjects([]));
}, []);
useEffect(() => {
if (type === "PROJECT_TRANSFER") {
void loadTransfers();
}
}, [type, loadTransfers]);
const whStock = useMemo(() => {
if (!whId) return [];
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
}, [data.inventory, whId]);
const handleSaveEdit = async () => {
if (!editingTransfer) return;
const n = parseNum(editQty);
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!editToProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
setEditBusy(true);
try {
await apiV26.projectTransfers.update(editingTransfer.id, {
quantity: n,
to_project_id: editToProjectId,
note: editNote,
}, user?.project_id);
toast.success("انتقال با موفقیت ویرایش شد ✓");
notifyDataChanged("inventory");
setEditingTransfer(null);
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ویرایش انتقال");
} finally {
setEditBusy(false);
}
};
const handleConfirmDelete = async () => {
if (!deleteConfirmTransfer) return;
setDeleteBusy(true);
try {
await apiV26.projectTransfers.delete(deleteConfirmTransfer.id, user?.project_id);
toast.success("انتقال لغو شد و موجودی به انبار مبدأ بازگردانده شد ✓");
notifyDataChanged("inventory");
setDeleteConfirmTransfer(null);
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در لغو انتقال");
} finally {
setDeleteBusy(false);
}
};
const submitPurchase = async () => { const submitPurchase = async () => {
const matName = matTxt.trim(); const matName = matTxt.trim();
const brandName = brandTxt.trim(); const brandName = brandTxt.trim();
@@ -327,82 +234,6 @@ export function ChangeInventoryPage() {
} }
}; };
const submitProjectTransfer = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
const unitName = unitTxt.trim();
const n = parseNum(qty);
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
if (!matName) return toast.error("نام متریال را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
setBusy(true);
try {
// Find matching inventory in the selected source warehouse
const invInWh = (data.inventory || []).filter(
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
);
const matchedInv = invInWh.find(
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
) || invInWh[0];
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
if (brandName && !bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let mid = matchedInv?.material.id;
if (!mid) {
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
mid = created.id;
} else {
mid = m.id;
}
}
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
await apiV26.projectTransfers.create({
from_warehouse_id: whId,
to_project_id: toProjectId,
material_id: mid,
brand_id: bid || null,
unit_id: uid,
quantity: n,
specs: finalSpecs,
note,
transfer_date: toServerIso(),
}, user?.project_id);
toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓");
notifyDataChanged("inventory");
setQty("");
setSpecsChips([]);
setNote("");
setMatTxt("");
setBrandTxt("");
setUnitTxt("");
setToProjectId("");
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی");
} finally {
setBusy(false);
}
};
const submitList = async (t: TransactionType) => { const submitList = async (t: TransactionType) => {
const ops: Record<string, unknown>[] = rows const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => { .map((r, i): Record<string, unknown> | null => {
@@ -525,7 +356,6 @@ export function ChangeInventoryPage() {
const doSubmit = () => { const doSubmit = () => {
try { try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransfer();
else if (type === "RETURN") void submitReturn(); else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer(); else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound(); else if (type === "FOUND") void submitFound();
@@ -540,9 +370,6 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
} }
if (type === "PROJECT_TRANSFER") {
return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0);
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") { if (type === "FOUND") {
@@ -636,47 +463,7 @@ export function ChangeInventoryPage() {
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs"> <div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */} {/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */}
{type === "PROJECT_TRANSFER" ? ( {!isTransfer ? (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">از (انبار مبدأ این پروژه) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setWhId(w.id); }}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
whId === w.id
? "bg-rose-600 text-white font-bold shadow-xs"
: "border border-line bg-bg/50 text-ink hover:bg-panel"
)}
>
{w.name}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">به (پروژه مقصد) *</label>
<select
className="mt-1 h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
value={toProjectId}
onChange={(e) => setToProjectId(e.target.value)}
>
<option value="">— انتخاب پروژه مقصد —</option>
{(allProjects || [])
.filter((p) => p.id !== user?.project_id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
) : !isTransfer ? (
<div className="mb-3 flex flex-col gap-3"> <div className="mb-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">انبار *</label> <label className="text-xs font-semibold text-muted">انبار *</label>
@@ -774,72 +561,9 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */} {/* فرم خرید / موجودی اولیه */}
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && ( {type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */}
{type === "PROJECT_TRANSFER" && whId && (
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>📦</span>
<span>موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
</span>
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
</div>
{whStock.length === 0 ? (
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
) : (
<div className="flex flex-wrap gap-2">
{whStock.map((r) => {
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
const specsSummary = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean)
.join(" · ");
return (
<button
key={r.id}
type="button"
onClick={() => {
setMatTxt(r.material.name);
setBrandTxt(r.brand?.name || "");
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
const chips = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean);
setSpecsChips(chips);
}}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
isSelected
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
: "border-line bg-panel hover:border-ink/40 text-ink"
)}
>
<span className="font-bold">{r.material.name}</span>
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
({r.brand?.name || "بدون برند"})
</span>
{specsSummary && (
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
[{specsSummary}]
</span>
)}
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
)}>
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
</span>
</button>
);
})}
</div>
)}
</div>
)}
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">متریال *</label> <label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox <Combobox
@@ -858,15 +582,13 @@ export function ChangeInventoryPage() {
)} )}
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted"> <label className="text-xs font-semibold text-muted">برند *</label>
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<Combobox <Combobox
value={brandTxt} value={brandTxt}
items={data.lookup.brands.map((b) => b.name)} items={data.lookup.brands.map((b) => b.name)}
allowAdd allowAdd
addSuffix="به عنوان برند جدید" addSuffix="به عنوان برند جدید"
placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"} placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={setBrandTxt} onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)} onChange={(it) => setBrandTxt(it.label)}
/> />
@@ -903,13 +625,11 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted"> <label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label>
مشخصه‌های فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<MultiSelectCombobox <MultiSelectCombobox
values={specsChips} values={specsChips}
onChange={setSpecsChips} onChange={setSpecsChips}
placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"} placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
className="w-full" className="w-full"
/> />
</div> </div>
@@ -917,7 +637,7 @@ export function ChangeInventoryPage() {
<label className="text-xs font-semibold text-muted">یادداشت</label> <label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea <textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm" className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
placeholder={type === "PROJECT_TRANSFER" ? "توضیحات بار ارسالی، شماره بارنامه، راننده…" : "شماره فاکتور، تأمین‌کننده، توضیحات…"} placeholder="شماره فاکتور، تأمین‌کننده، توضیحات…"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
/> />
@@ -982,234 +702,11 @@ export function ChangeInventoryPage() {
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95" className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
style={{ background: meta.color }} style={{ background: meta.color }}
> >
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "انتقال بین کارگاهی" ? "ثبت انتقال بین کارگاهی و کسر از انبار" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`} ✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
</button> </button>
</div> </div>
</div> </div>
{/* بخش سوابق انتقال‌های بین کارگاهی این پروژه */}
{type === "PROJECT_TRANSFER" && (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line pb-3">
<div>
<h3 className="text-sm font-bold text-foreground">📋 سوابق انتقال‌های بین کارگاهی این پروژه</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
مصالح ارسالی به سایر پروژه‌ها؛ تا زمان تأیید تحویل در مقصد می‌توانید آن‌ها را ویرایش یا لغو کنید.
</p>
</div>
<button
type="button"
onClick={() => void loadTransfers()}
className="rounded-lg border border-line px-3 py-1.5 text-xs font-medium hover:bg-bg transition-colors"
>
🔄 به‌روزرسانی لیست
</button>
</div>
{loadingTransfers ? (
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
) : !(transfers || []).length ? (
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-right text-xs min-w-[650px]">
<thead>
<tr className="border-b border-line text-muted">
<th className="py-2.5 pe-3">تاریخ انتقال</th>
<th className="py-2.5 pe-3">متریال / برند</th>
<th className="py-2.5 pe-3">مشخصات فنی</th>
<th className="py-2.5 pe-3">مقدار</th>
<th className="py-2.5 pe-3">مبدأ → مقصد</th>
<th className="py-2.5 pe-3">وضعیت</th>
<th className="py-2.5 text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{(transfers || []).map((t) => {
const isInTransit = t.status === "in_transit";
return (
<tr key={t.id} className="hover:bg-bg/40">
<td className="py-3 pe-3 text-muted">{fmtDate(t.transfer_date)}</td>
<td className="py-3 pe-3 font-semibold">
<div>{t.material?.name}</div>
<div className="text-[11px] text-muted font-normal">{t.brand?.name || "بدون برند"}</div>
</td>
<td className="py-3 pe-3 text-muted text-[11.5px]">
{t.specs && Object.keys(t.specs).length > 0
? Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")
: "—"}
</td>
<td className="py-3 pe-3 font-bold text-foreground">
{faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code}
</td>
<td className="py-3 pe-3">
<div className="text-[11px] text-muted">از: {t.from_warehouse?.name}</div>
<div className="text-[12px] font-medium text-foreground">به: {t.to_project?.name}</div>
</td>
<td className="py-3 pe-3">
{isInTransit ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
🟡 در انتظار تأیید مقصد
</span>
) : (
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 border border-emerald-500/20">
🟢 تحویل‌شده در مقصد
</span>
{t.to_warehouse?.name && (
<span className="text-[10.5px] text-muted">انبار: {t.to_warehouse.name}</span>
)}
</div>
)}
</td>
<td className="py-3 text-center">
{isInTransit ? (
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
onClick={() => {
setEditingTransfer(t);
setEditQty(String(t.quantity));
setEditToProjectId(t.to_project?.id || "");
setEditNote(t.note || "");
}}
className="rounded-lg border border-line px-2.5 py-1 text-[11.5px] font-medium hover:bg-bg transition-colors"
title="ویرایش انتقال"
>
✏️ ویرایش
</button>
<button
type="button"
onClick={() => setDeleteConfirmTransfer(t)}
className="rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1 text-[11.5px] font-medium text-rose-700 hover:bg-rose-100 transition-colors"
title="لغو انتقال"
>
✕ لغو
</button>
</div>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted font-medium">
🔒 قفل
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* مودال ویرایش انتقال */}
{editingTransfer && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !editBusy && setEditingTransfer(null)}>
<div className="w-full max-w-md rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-[14px] font-bold">✏️ ویرایش انتقال بین کارگاهی</h3>
<p className="mb-3 text-[11.5px] text-muted">
تغییر تعداد در انبار مبدأ تعدیل می‌گردد. پس از تحویل در مقصد، ویرایش غیرفعال خواهد شد.
</p>
<div className="flex flex-col gap-3">
<div className="rounded-lg border border-line bg-bg/50 p-2.5 text-xs">
<div>متریال: <b>{editingTransfer.material?.name}</b></div>
<div className="text-muted mt-0.5">انبار مبدأ: {editingTransfer.from_warehouse?.name}</div>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">پروژه مقصد *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
value={editToProjectId}
onChange={(e) => setEditToProjectId(e.target.value)}
>
{(allProjects || [])
.filter((p) => p.id !== user?.project_id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تعداد ({editingTransfer.unit?.name_fa || editingTransfer.unit?.code}) *
</label>
<input
inputMode="decimal"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
value={editQty}
onChange={(e) => setEditQty(e.target.value)}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setEditingTransfer(null)}
disabled={editBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
onClick={() => void handleSaveEdit()}
disabled={editBusy}
>
{editBusy ? "در حال ذخیره…" : "ذخیره تغییرات"}
</button>
</div>
</div>
</div>
)}
{/* دیالوگ تأیید لغو انتقال */}
{deleteConfirmTransfer && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !deleteBusy && setDeleteConfirmTransfer(null)}>
<div className="w-full max-w-sm rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-[14px] font-bold text-rose-600">⚠️ لغو انتقال بین کارگاهی</h3>
<p className="mb-3 text-[12px] leading-6 text-foreground">
آیا از لغو این انتقال اطمینان دارید؟ با لغو انتقال، مقدار <b>{faNum(deleteConfirmTransfer.quantity)} {deleteConfirmTransfer.unit?.name_fa || deleteConfirmTransfer.unit?.code}</b> از متریال <b>{deleteConfirmTransfer.material?.name}</b> فوراً به انبار مبدأ ({deleteConfirmTransfer.from_warehouse?.name}) بازگردانده خواهد شد.
</p>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setDeleteConfirmTransfer(null)}
disabled={deleteBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-rose-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-rose-700 disabled:opacity-50"
onClick={() => void handleConfirmDelete()}
disabled={deleteBusy}
>
{deleteBusy ? "در حال لغو…" : "بله، لغو و بازگشت موجودی"}
</button>
</div>
</div>
</div>
)}
{/* مودال تأیید شمارش موجودی */} {/* مودال تأیید شمارش موجودی */}
{countConfirm && ( {countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
@@ -1274,11 +771,7 @@ export function ChangeInventoryPage() {
)} )}
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
{type === "PROJECT_TRANSFER" {isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
? "انتقال بین کارگاهی: موجودی فوراً از انبار مبدأ کسر شده و پس از تأیید تحویل توسط مدیر پروژه مقصد، وارد انبار مقصد می‌شود."
: isTransfer
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react"; import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks"; import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -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;
@@ -68,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 () => {
@@ -116,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 justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div> <div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge> <Badge variant="outline">{topicName}</Badge>
</div> </div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]"> {avgLeadTime != null && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
<Clock className="h-3.5 w-3.5 text-emerald-400" />
<span>{avgLeadTime} روز</span>
</span>
)}
</div>
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
<span> <span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"} <MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span> </span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"} {/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
</span> <div className="flex items-center">
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
<b className="me-1">تلفن:</b>
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
dir="ltr"
title="تماس مستقیم با تأمین‌کننده"
>
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span className="text-muted">—</span>
)}
</div>
<span> <span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"} <User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span> </span>
<span>
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
</span>
</div> </div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Link <Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`} to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
@@ -151,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" /> متریال‌های تأمین‌شده
@@ -191,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>
+33 -2
View File
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters"; import { fmtNum } from "../../lib/formatters";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
return suppliers.filter( return suppliers.filter(
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)), (s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
@@ -29,7 +38,11 @@ export function SupplierCard({
return ( return (
<div className="sup-card"> <div className="sup-card">
<div className="sc-head flex items-start justify-between gap-2"> <div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug"> {/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */}
<Link
to={`/suppliers/${s.id}`}
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
>
{s.name} {s.name}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
@@ -49,17 +62,34 @@ export function SupplierCard({
)} )}
</div> </div>
</div> </div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"} <MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
</span> </span>
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
title="تماس مستقیم با تأمین‌کننده"
dir="ltr"
>
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span> <span>
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"} <Phone className="me-1 inline h-3 w-3 text-muted" /> —
</span> </span>
)}
<span> <span>
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"} <User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
</span> </span>
</div> </div>
{materials.length > 0 && ( {materials.length > 0 && (
<div className="mt-2 border-t border-line pt-1.5"> <div className="mt-2 border-t border-line pt-1.5">
<div className="mb-1 flex flex-wrap gap-1"> <div className="mb-1 flex flex-wrap gap-1">
@@ -71,6 +101,7 @@ export function SupplierCard({
</div> </div>
</div> </div>
)} )}
<div className="sc-actions"> <div className="sc-actions">
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline"> <Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
+145 -57
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus, Copy } from "lucide-react";
import type { Material } from "../../lib/types"; import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks"; import { useMaterials } from "../../hooks";
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
import { faNum } from "../../lib/formatters";
export interface ItemDraft { export interface ItemDraft {
tempId: string; tempId: string;
@@ -33,7 +35,6 @@ export interface Step2Props {
onRemember: (rec: MaterialRec) => void; onRemember: (rec: MaterialRec) => void;
} }
// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی)
export function itemRowLabel(it: ItemDraft): string { export function itemRowLabel(it: ItemDraft): string {
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean); const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)"; const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)";
@@ -41,7 +42,6 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
_topic: string, _topic: string,
@@ -51,7 +51,6 @@ export function materialOptions(
apiBrands: string[] = [], apiBrands: string[] = [],
apiSpecs: string[] = [], apiSpecs: string[] = [],
): string[] { ): string[] {
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials; let list = materials;
const cat = item.cat.trim(); const cat = item.cat.trim();
@@ -67,21 +66,17 @@ export function materialOptions(
}; };
if (field === "brand") { if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و متریال
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b)); apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa")); return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
} }
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ
// هم‌نام پیشنهاد می‌شوند.
if (cat) if (cat)
list = list.filter( list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat), (m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
); );
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
const base = new Set<string>(); const base = new Set<string>();
apiSpecs.forEach((s) => s && base.add(s)); apiSpecs.forEach((s) => s && base.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s))); materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
@@ -90,7 +85,6 @@ 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();
@@ -154,6 +148,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
]); ]);
}; };
// قابلیت تکرار قلم در ردیف بعد با کپی عمیق فیلدها و شناسه یکتا
const duplicate = (index: number) => {
const target = items[index];
if (!target) return;
const clonedItem: ItemDraft = {
...target,
tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6),
specs: [...target.specs],
};
const nextItems = [...items];
nextItems.splice(index + 1, 0, clonedItem);
onItemsChange(nextItems);
};
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => { const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return; if (!selectedTopic) return;
let rec: MaterialRec; let rec: MaterialRec;
@@ -167,46 +177,99 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return; if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs }; rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
} }
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
// همان هویتِ متریال انجام می‌شود، نه دسته.
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs); const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
if (dup) return; if (dup) return;
onRemember(rec); onRemember(rec);
}; };
const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal";
return ( return (
<div> <div className="space-y-4">
<h3 className="mb-1 text-[15px] font-bold"> {/* هدر */}
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span> <div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
۲
</span>
<h3 className="text-sm sm:text-base font-bold text-ink">
اقلام سفارش اقلام سفارش
</h3> </h3>
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} /> </div>
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
<div className="item-row wizard-item-row item-row-head mb-1 pe-7"> {faNum(items.length)} قلم
<span>متریال</span> </span>
<span>برند</span> </div>
<span>مشخصه</span> <p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
<span>تعداد</span> مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینه‌های جدید با تایپ ذخیره می‌شوند.
<span>واحد</span> </p>
<span>قیمت واحد</span>
<span>قیمت کل</span>
</div> </div>
<div data-field="line_items" className="flex flex-col gap-2"> {/* لیست کارت‌های اقلام */}
{items.map((it) => ( <div data-field="line_items" className="flex flex-col gap-3">
<div key={it.tempId} className="item-row wizard-item-row items-center"> {items.map((it, idx) => (
<div
key={it.tempId}
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
>
{/* سربرگ قلم */}
<div className="flex items-center justify-between border-b border-line pb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-ink truncate">
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span>
</div>
{/* اکشن‌های کارت: کپی و حذف */}
<div className="flex items-center gap-1">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted hover:text-ink hover:bg-line/50 transition-colors"
title="تکرار این قلم"
onClick={() => duplicate(idx)}
>
<Copy className="h-3.5 w-3.5" />
<span className="text-[11px]">کپی</span>
</button>
{items.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
</div>
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
<Combobox <Combobox
value={it.cat} value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })} onChange={(x) => patch(it.tempId, { cat: x.label })}
items={allMaterialNames} items={allMaterialNames}
allowAdd allowAdd
addSuffix="به عنوان متریال جدید" addSuffix="به عنوان متریال جدید"
placeholder="متریال" placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })} onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)} onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-[12px] !h-9" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
<Combobox <Combobox
value={it.brand} value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })} onChange={(x) => patch(it.tempId, { brand: x.label })}
@@ -216,34 +279,53 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="برند" placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })} onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)} onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-[12px] !h-9" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div>
</div>
{/* ردیف ۲: مشخصه فنی */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox <MultiSelectCombobox
values={it.specs} values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })} onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)} options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه" placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
className="min-w-0" className="min-w-0"
/> />
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
<NumberInput <NumberInput
value={it.qty} value={it.qty}
onChange={(qty) => { onChange={(qty) => {
patch(it.tempId, { patch(it.tempId, {
qty, qty,
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total, total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
}); });
}} }}
placeholder="تعداد" placeholder="مقدار"
showWords className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
<Combobox <Combobox
value={it.unit} value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })} onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))} items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd allowAdd
addSuffix="به عنوان واحد جدید" addSuffix="به عنوان واحد جدید"
placeholder="واحد" placeholder="واحد (شاخه، متر، کیلو...)"
onInputChange={(v) => patch(it.tempId, { unit: v })} onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => { onAdd={(v) => {
const name = v.trim(); const name = v.trim();
@@ -257,56 +339,62 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}) })
.catch(() => {}); .catch(() => {});
}} }}
inputClassName="!text-[12px] !h-9" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div>
</div>
{/* ردیف ۴: قیمت هر واحد */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput <NumberInput
value={it.price} value={it.price}
onChange={(price) => { onChange={(price) => {
patch(it.tempId, { patch(it.tempId, {
price, price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total, total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
}); });
}} }}
placeholder="قیمت واحد" placeholder="قیمت هر واحد"
showWords showWords
maxDecimals={2} className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div>
{/* ردیف ۵: قیمت کل قلم */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
value={it.total} value={it.total}
onChange={(total) => { onChange={(total) => {
patch(it.tempId, { patch(it.tempId, {
total, total,
price: price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price, total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
}); });
}} }}
placeholder="قیمت کل" placeholder="قیمت کل قلم"
showWords showWords
className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div>
{/* دکمه حذف مدرن با آیکون سطل زباله */} </div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div> </div>
))} ))}
</div> </div>
{/* دکمه افزودن قلم با استایل برجسته و خط‌چین */} {/* دکمه افزودن قلم جدید */}
<button <button
type="button" type="button"
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150" className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
onClick={add} onClick={add}
> >
<span className="text-base font-bold leading-none">+</span> <Plus className="h-4 w-4" />
<span>افزودن قلم جدید</span> <span>افزودن قلم جدید</span>
</button> </button>
</div> </div>
+32 -14
View File
@@ -9,40 +9,58 @@ export interface StepperProps {
onGoto: (step: number) => void; onGoto: (step: number) => void;
} }
const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"]; const STEPS = [
{ id: 1, label: "تأمین‌کننده" },
{ id: 2, label: "اقلام" },
{ id: 3, label: "شرایط پرداخت" },
{ id: 4, label: "پروژه" },
{ id: 5, label: "بازبینی و ثبت" },
];
// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است // نوار ۵ مرحله‌ای ویزارد — واکنش‌گرا و ضدشکستگی در موبایل
// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی
export function Stepper({ current, completedSteps, onGoto }: StepperProps) { export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
return ( return (
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm"> <div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
{STEPS.map((label, i) => { {STEPS.map(({ id: step, label }) => {
const step = i + 1;
const isCurrent = step === current; const isCurrent = step === current;
const isComplete = completedSteps.has(step); const isComplete = completedSteps.has(step);
return ( return (
<button <button
key={step} key={step}
type="button" type="button"
className={cn( className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors", "flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
// در موبایل مرحله جاری فضا می‌گیرد و بقیه جمع‌وجور می‌مانند تا سطر نشکند
isCurrent isCurrent
? "bg-[#2563eb] font-bold text-white" ? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
: isComplete : "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
? "bg-green-bg font-medium text-green" isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
: "hover:bg-slate-bg",
)} )}
onClick={() => onGoto(step)} onClick={() => onGoto(step)}
> >
<span <span
className={cn( className={cn(
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold", "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted", isCurrent
? "bg-white/20 text-white"
: isComplete
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
: "bg-muted/15 text-muted",
)} )}
> >
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)} {isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
</span> </span>
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نام‌ها نمایش دارند */}
<span
className={cn(
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
!isCurrent && "hidden md:inline",
)}
>
{label} {label}
</span>
</button> </button>
); );
})} })}
+23 -39
View File
@@ -127,11 +127,9 @@ export function WizardPage() {
goToStep(currentStep + 1); goToStep(currentStep + 1);
}; };
const rememberMaterial = (_rec: MaterialRec) => { const rememberMaterial = (_rec: MaterialRec) => {};
// رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
};
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ---------- // ---------- پروژه‌ها ----------
const projectNames = useMemo(() => { const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name)); const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => { quotes.forEach((q) => {
@@ -159,7 +157,7 @@ export function WizardPage() {
}); });
}; };
// ---------- خلاصه‌ی مرحله ۵ ---------- // ---------- خلاصه مرحله ۵ ----------
const review = useMemo(() => { const review = useMemo(() => {
let grandTotal = 0; let grandTotal = 0;
items.forEach((it) => { items.forEach((it) => {
@@ -202,23 +200,14 @@ export function WizardPage() {
const combined = `${field} ${msg}`.toLowerCase(); const combined = `${field} ${msg}`.toLowerCase();
// ۱. تأمین‌کننده
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" }; if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
// ۲. اقلام / متریال / واحد / تعداد / قیمت
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) { if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
return { step: 2, field: field || "line_items" }; return { step: 2, field: field || "line_items" };
} }
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) { if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
return { step: 3, field: field || "termsheets" }; return { step: 3, field: field || "termsheets" };
} }
// ۴. پروژه
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" }; if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
// ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" }; if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" }; if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" }; if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" };
@@ -291,10 +280,6 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید"; const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName)); let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) { if (!material) {
const matched = matchMaterial( const matched = matchMaterial(
@@ -426,7 +411,7 @@ export function WizardPage() {
}; };
return ( return (
<div className="min-h-screen"> <div className="min-h-screen pb-10">
<Header <Header
title="افزودن پیشنهاد جدید" title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید" sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
@@ -434,10 +419,12 @@ export function WizardPage() {
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
/> />
<PageLayout> <PageLayout>
{/* نوار مراحل استپر */}
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} /> <Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
{currentStep === 1 && ( {currentStep === 1 && (
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step1TopicSupplier <Step1TopicSupplier
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
onTopicSelect={(t) => { onTopicSelect={(t) => {
@@ -461,12 +448,12 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} /> <Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div> </div>
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step3Termsheets <Step3Termsheets
cards={cards} cards={cards}
onCardsChange={setCards} onCardsChange={setCards}
@@ -477,7 +464,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step4Projects <Step4Projects
items={items} items={items}
projects={projectNames} projects={projectNames}
@@ -491,7 +478,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 5 && ( {currentStep === 5 && (
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step5Review <Step5Review
quoteDate={quoteDate} quoteDate={quoteDate}
onQuoteDate={setQuoteDate} onQuoteDate={setQuoteDate}
@@ -506,15 +493,14 @@ export function WizardPage() {
</div> </div>
)} )}
{/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */} {/* پاورقی کنترل مراحل */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm"> <div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
<div> <div>
{currentStep > 1 && ( {currentStep > 1 && (
<button <button
type="button" type="button"
onClick={() => goToStep(currentStep - 1)} onClick={() => goToStep(currentStep - 1)}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer" className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
<span>قبلی</span> <span>قبلی</span>
@@ -522,21 +508,19 @@ export function WizardPage() {
)} )}
</div> </div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */} <div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-3">
<Link <Link
to="/" to="/"
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline" className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
> >
انصراف انصراف
</Link> </Link>
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{currentStep < TOTAL_STEPS && ( {currentStep < TOTAL_STEPS && (
<button <button
type="button" type="button"
onClick={next} onClick={next}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer" className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
> >
<span>بعدی</span> <span>بعدی</span>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -547,7 +531,7 @@ export function WizardPage() {
<button <button
type="button" type="button"
onClick={() => void save()} onClick={() => void save()}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer" className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
> >
<span>ذخیره پیشنهاد</span> <span>ذخیره پیشنهاد</span>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
@@ -556,21 +540,21 @@ export function WizardPage() {
</div> </div>
</div> </div>
{/* دیالوگ ذخیره با فیلدهای خالی */} {/* دیالوگ فیلدهای خالی */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>فیلدهای خالی</DialogTitle> <DialogTitle>فیلدهای خالی</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="text-[13.5px] leading-7"> <div className="text-[13.5px] leading-7 text-gray-700">
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟ هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
</div> </div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
{emptyFields.map((f) => ( {emptyFields.map((f) => (
<button <button
key={f.step} key={f.step}
type="button" type="button"
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark" className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
onClick={() => { onClick={() => {
setConfirmOpen(false); setConfirmOpen(false);
goToStep(f.step); goToStep(f.step);
@@ -597,7 +581,7 @@ export function WizardPage() {
goToStep(emptyFields[0]?.step ?? 1); goToStep(emptyFields[0]?.step ?? 1);
}} }}
> >
تکمیل فیلد ها تکمیل فیلدها
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
-33
View File
@@ -551,35 +551,6 @@ CREATE TABLE standard_conversions (
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit) CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
); );
-- Inter-project material transfers
CREATE TABLE project_transfers (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
specs_hash TEXT NOT NULL DEFAULT 'default',
note TEXT,
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
transferred_by TEXT,
received_at TIMESTAMPTZ,
received_by TEXT,
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
receipt_allocations JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_project_transfers_from ON project_transfers(from_project_id, status);
CREATE INDEX idx_project_transfers_to ON project_transfers(to_project_id, status);
-- ============================================================================ -- ============================================================================
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification) -- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
-- ============================================================================ -- ============================================================================
@@ -608,10 +579,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
BEFORE UPDATE ON quote_attachments BEFORE UPDATE ON quote_attachments
FOR EACH ROW EXECUTE FUNCTION set_updated_at(); FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_project_transfers_updated_at
BEFORE UPDATE ON project_transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================================ -- ============================================================================
-- AUTOMATION TRIGGERS (v2.5 + v2.6) -- AUTOMATION TRIGGERS (v2.5 + v2.6)
-- ============================================================================ -- ============================================================================
BIN
View File
Binary file not shown.