Compare commits
18
Commits
da883a28c0
...
prod-v10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3931baadd5 | ||
|
|
1bf55ba8a6 | ||
|
|
5458a2f99f | ||
|
|
af092fe4c8 | ||
|
|
d85ede7d11 | ||
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f |
@@ -247,6 +247,7 @@ _V26_DDL = [
|
|||||||
physical_qty NUMERIC(18,6),
|
physical_qty NUMERIC(18,6),
|
||||||
transfer_pair_id UUID,
|
transfer_pair_id UUID,
|
||||||
performed_by TEXT,
|
performed_by TEXT,
|
||||||
|
delivered_to TEXT,
|
||||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -258,6 +259,9 @@ _V26_DDL = [
|
|||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
|
||||||
|
""",
|
||||||
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
||||||
"""
|
"""
|
||||||
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
||||||
@@ -432,3 +436,48 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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
|
||||||
@@ -107,6 +108,7 @@ 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
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from app.models.procurement import (
|
|||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.models.project_transfer import ProjectTransfer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"Unit",
|
"Unit",
|
||||||
@@ -60,4 +62,5 @@ __all__ = [
|
|||||||
"ItemDecision",
|
"ItemDecision",
|
||||||
"ItemReceipt",
|
"ItemReceipt",
|
||||||
"QuoteAttachment",
|
"QuoteAttachment",
|
||||||
|
"ProjectTransfer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
|
|||||||
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
||||||
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
||||||
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
||||||
|
delivered_to: Mapped[str | None] = mapped_column(Text)
|
||||||
transaction_date: Mapped[datetime] = mapped_column(
|
transaction_date: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ from app.routers import (
|
|||||||
material_categories,
|
material_categories,
|
||||||
materials,
|
materials,
|
||||||
pm,
|
pm,
|
||||||
|
project_transfers,
|
||||||
projects,
|
projects,
|
||||||
quote_events,
|
quote_events,
|
||||||
quotes,
|
quotes,
|
||||||
@@ -50,5 +51,6 @@ ROUTERS = [
|
|||||||
standard_conversions.router,
|
standard_conversions.router,
|
||||||
inventory.router,
|
inventory.router,
|
||||||
pm.router,
|
pm.router,
|
||||||
|
project_transfers.router,
|
||||||
users.router,
|
users.router,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
@@ -197,6 +198,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
|
|||||||
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||||
"performed_by": tx.performed_by,
|
"performed_by": tx.performed_by,
|
||||||
|
"delivered_to": tx.delivered_to,
|
||||||
"transaction_date": to_iso(tx.transaction_date),
|
"transaction_date": to_iso(tx.transaction_date),
|
||||||
"note": tx.note,
|
"note": tx.note,
|
||||||
}
|
}
|
||||||
@@ -397,9 +399,18 @@ async def list_transactions(
|
|||||||
if transaction_type is not None:
|
if transaction_type is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||||
|
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||||
|
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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)}
|
||||||
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
specs: dict | None = None
|
specs: dict | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
delivered_to: str | None = None
|
||||||
transaction_date: datetime | None = None
|
transaction_date: datetime | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
@@ -290,6 +290,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
@@ -319,6 +320,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=-quantity,
|
quantity=-quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
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())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||||
|
|
||||||
|
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||||
|
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||||
|
|
||||||
|
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||||
|
|
||||||
|
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||||
|
(`backend/app/routers/inventory.py` — list_transactions)
|
||||||
|
|
||||||
|
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||||
|
|
||||||
|
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||||
|
|
||||||
|
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||||
|
|
||||||
|
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||||
|
|
||||||
|
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||||
|
|
||||||
|
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||||
|
|
||||||
|
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||||
|
|
||||||
|
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||||
|
|
||||||
|
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||||
|
|
||||||
|
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||||
|
|
||||||
|
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||||
|
|
||||||
|
## ۵. 🟡 Audit Trail ناقص است
|
||||||
|
|
||||||
|
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||||
|
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||||
|
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||||
|
- CRUD انبارها و تغییرات کاربران
|
||||||
|
|
||||||
|
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||||
|
|
||||||
|
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||||
|
|
||||||
|
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||||
|
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||||
|
|
||||||
|
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||||
|
|
||||||
|
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||||
|
|
||||||
|
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||||
|
|
||||||
|
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||||
|
|
||||||
|
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||||
|
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||||
|
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||||
|
|
||||||
|
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||||
|
|
||||||
|
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||||
|
|
||||||
|
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||||
|
|
||||||
|
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||||
|
|
||||||
|
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||||
|
|
||||||
|
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||||
|
|
||||||
|
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||||
|
|
||||||
|
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||||
|
|
||||||
|
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چیزهایی که بررسی شد و درست است ✅
|
||||||
|
|
||||||
|
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||||
|
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||||
|
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||||
|
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||||
|
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||||
|
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||||
import { normalize } from "../lib/normalize";
|
import { normalize } from "../lib/normalize";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
@@ -17,6 +17,7 @@ export interface ComboboxProps {
|
|||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,6 +26,8 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
openOnFocus?: boolean;
|
openOnFocus?: boolean;
|
||||||
@@ -44,6 +47,7 @@ export function Combobox({
|
|||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
@@ -52,6 +56,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
inputRef,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
@@ -125,6 +130,7 @@ export function Combobox({
|
|||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex 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-50",
|
"flex 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-50",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
@@ -182,9 +188,15 @@ export function Combobox({
|
|||||||
onMouseEnter={() => setHighlight(filtered.length)}
|
onMouseEnter={() => setHighlight(filtered.length)}
|
||||||
>
|
>
|
||||||
<span className="font-bold">+</span>
|
<span className="font-bold">+</span>
|
||||||
|
{addPrefix ? (
|
||||||
|
<span>
|
||||||
|
{addPrefix} «<b>{text.trim()}</b>»
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span>
|
<span>
|
||||||
«<b>{text.trim()}</b>» {addSuffix}
|
«<b>{text.trim()}</b>» {addSuffix}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
|
|||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 w-full my-2">
|
<div className="flex items-start gap-2 w-full my-2">
|
||||||
{/* کادر انتخاب تاریخ */}
|
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||||
<div className="w-36 shrink-0">
|
<div className="w-28 shrink-0">
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={milestone.when_iso}
|
value={milestone.when_iso}
|
||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export interface NumberInputProps {
|
|||||||
onChange: (n: number | null) => void;
|
onChange: (n: number | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیشفرض: راستچین)
|
||||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||||
inputMode?: "numeric" | "decimal" | undefined;
|
inputMode?: "numeric" | "decimal" | undefined;
|
||||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
maxDecimals?: number; // حداکثر رقم اعشار
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export function NumberInput({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
|
inputClassName,
|
||||||
|
align = "right",
|
||||||
showWords = false,
|
showWords = false,
|
||||||
inputMode = "numeric",
|
inputMode = "numeric",
|
||||||
maxDecimals,
|
maxDecimals,
|
||||||
@@ -36,8 +40,6 @@ export function NumberInput({
|
|||||||
|
|
||||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||||
|
|
||||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
|
||||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cur = parseNum(text);
|
const cur = parseNum(text);
|
||||||
if (value != null && value !== cur) {
|
if (value != null && value !== cur) {
|
||||||
@@ -60,19 +62,28 @@ export function NumberInput({
|
|||||||
|
|
||||||
const n = parseNum(text);
|
const n = parseNum(text);
|
||||||
|
|
||||||
|
const alignClass =
|
||||||
|
align === "center"
|
||||||
|
? "!text-center text-center"
|
||||||
|
: align === "left"
|
||||||
|
? "!text-left text-left"
|
||||||
|
: "!text-right text-right";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-w-0", className)}>
|
<div className={cn("min-w-0 w-full", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className={cn(
|
||||||
|
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
alignClass,
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value;
|
const raw = e.target.value;
|
||||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
|
||||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
|
||||||
const parsed = parseNum(raw);
|
const parsed = parseNum(raw);
|
||||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||||
if (parsed != null && !trailingSep) {
|
if (parsed != null && !trailingSep) {
|
||||||
@@ -85,16 +96,11 @@ export function NumberInput({
|
|||||||
}}
|
}}
|
||||||
onBlur={() => commit(text)}
|
onBlur={() => commit(text)}
|
||||||
/>
|
/>
|
||||||
{showWords && (
|
{showWords && n != null && (
|
||||||
<span className={cn("num-pill", n == null && "empty")}>
|
<div className="mt-1 block text-right text-[11px] text-muted">
|
||||||
{n != null ? (
|
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
|
||||||
<>
|
<span className="text-muted">({toWords(n)})</span>
|
||||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+70
-10
@@ -30,6 +30,10 @@ 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";
|
||||||
@@ -239,20 +243,32 @@ 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<{ data: T }>(path);
|
const res = await request<any>(path);
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
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<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
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<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
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<{ data: T }>(path, { method: "DELETE" });
|
const res = await request<any>(path, { method: "DELETE" });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
||||||
@@ -1686,16 +1702,23 @@ export const apiV26 = {
|
|||||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||||
return data.map(rawInventory);
|
return data.map(rawInventory);
|
||||||
},
|
},
|
||||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||||
},
|
},
|
||||||
|
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||||
|
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||||
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
|
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||||
|
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||||
|
},
|
||||||
createTransaction: async (body: {
|
createTransaction: async (body: {
|
||||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||||
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
||||||
note?: string | null; transaction_date?: string | null;
|
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
|
||||||
}): Promise<InventoryTransaction[]> => {
|
}): Promise<InventoryTransaction[]> => {
|
||||||
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
||||||
},
|
},
|
||||||
@@ -1746,4 +1769,41 @@ 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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export interface InventoryTransaction {
|
|||||||
physical_qty?: number | null;
|
physical_qty?: number | null;
|
||||||
transfer_pair_id?: string | null;
|
transfer_pair_id?: string | null;
|
||||||
performed_by?: string | null;
|
performed_by?: string | null;
|
||||||
|
delivered_to?: string | null;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
material?: { id: string; name: string } | null;
|
material?: { id: string; name: string } | null;
|
||||||
@@ -200,3 +201,52 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,132 @@ import { FileList } from "../../components/FileList";
|
|||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||||
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
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; // null = پیشفرض «گزینه N»
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcceptModalProps {
|
export interface AcceptModalProps {
|
||||||
@@ -222,6 +343,7 @@ 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("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||||
@@ -266,7 +388,6 @@ 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) {
|
for (const li of active) {
|
||||||
const allocs = itemAllocations[li.id] || [];
|
const allocs = itemAllocations[li.id] || [];
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
@@ -290,14 +411,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// الزام ۳: جلوگیری از انتخاب پروژه تکراری برای یک قلم
|
|
||||||
const pNames = allocs.map((a) => a.project_name.trim());
|
const pNames = allocs.map((a) => a.project_name.trim());
|
||||||
if (new Set(pNames).size !== pNames.length) {
|
if (new Set(pNames).size !== pNames.length) {
|
||||||
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// الزام ۵: مقایسه با دقت اعشاری بالا
|
|
||||||
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||||
const diff = Math.abs(sumQty - targetQty);
|
const diff = Math.abs(sumQty - targetQty);
|
||||||
if (diff > 0.0001) {
|
if (diff > 0.0001) {
|
||||||
@@ -308,7 +427,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// انتساب تخصیصهای چندپروژهای به اقلام
|
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const allocs = itemAllocations[li.id];
|
const allocs = itemAllocations[li.id];
|
||||||
@@ -343,7 +461,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">
|
||||||
@@ -521,9 +639,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ردیفهای تخصیص */}
|
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||||||
<div className="flex flex-col gap-2.5 mb-3">
|
<div className="flex flex-col gap-2.5 mb-3">
|
||||||
{allocs.map((alloc, idx) => {
|
{allocs.map((alloc) => {
|
||||||
const otherUsedProjects = new Set(
|
const otherUsedProjects = new Set(
|
||||||
allocs
|
allocs
|
||||||
.filter((a) => a.rowId !== alloc.rowId)
|
.filter((a) => a.rowId !== alloc.rowId)
|
||||||
@@ -531,19 +649,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const words = numberToPersianWords(alloc.quantity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={alloc.rowId}
|
key={alloc.rowId}
|
||||||
className="flex flex-wrap sm:flex-nowrap items-center gap-2 bg-white p-2.5 rounded-lg border border-line"
|
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||||||
>
|
>
|
||||||
<span className="text-[11px] text-muted w-5 text-center shrink-0 font-mono">
|
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||||||
{faNum(idx + 1)}.
|
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||||||
</span>
|
<div className="flex-1 min-w-0">
|
||||||
|
|
||||||
{/* انتخاب پروژه (الزام ۳: عدم امکان انتخاب پروژه تکراری) */}
|
|
||||||
<div className="flex-1 min-w-[180px]">
|
|
||||||
<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"
|
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"
|
||||||
value={alloc.project_name}
|
value={alloc.project_name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateAllocationRow(li.id, alloc.rowId, {
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
@@ -563,14 +680,28 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مقدار تخصیص و برچسب واحد */}
|
{/* دکمه حذف موبایل */}
|
||||||
<div className="relative flex items-center w-36 shrink-0">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="مقدار"
|
placeholder="مقدار"
|
||||||
className="w-full h-9 rounded-md border border-line bg-panel pe-10 ps-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-left"
|
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"
|
dir="ltr"
|
||||||
value={alloc.quantity}
|
value={alloc.quantity}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -579,17 +710,27 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="absolute end-2 text-[11px] text-muted pointer-events-none select-none">
|
<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}
|
{li.unit}
|
||||||
</span>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={allocs.length <= 1}
|
disabled={allocs.length <= 1}
|
||||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||||
className="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"
|
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="حذف این ردیف تخصیص"
|
title="حذف این ردیف تخصیص"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
@@ -599,7 +740,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر کارت قلم: دکمه افزودن ردیف جدید */}
|
{/* فوتر کارت قلم */}
|
||||||
<div className="flex items-center justify-between pt-1">
|
<div className="flex items-center justify-between pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -425,7 +479,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 +494,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 +565,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 +608,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 +645,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 +670,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 +749,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 +767,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 +789,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 +815,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 +890,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 +942,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 } from "../../lib/types";
|
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
|
||||||
import { faNum, fmtDate } from "../../lib/formatters";
|
import { faNum, fmtDate } from "../../lib/formatters";
|
||||||
|
|
||||||
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
||||||
@@ -25,24 +25,31 @@ 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] = await Promise.all([
|
const [arr, whs, incoming] = 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(arr);
|
setItems(Array.isArray(arr) ? arr : []);
|
||||||
setWarehouses(whs);
|
setWarehouses(Array.isArray(whs) ? 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);
|
||||||
}
|
}
|
||||||
@@ -212,6 +219,53 @@ 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">
|
||||||
@@ -233,19 +287,171 @@ 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 ? (
|
) : !(orders || []).length && !(incomingTransfers || []).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);
|
||||||
@@ -401,7 +607,8 @@ export function ArrivalsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -129,9 +129,10 @@ export function DailyControlPage() {
|
|||||||
const wGroups = useMemo(() => {
|
const wGroups = useMemo(() => {
|
||||||
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
||||||
withdrawals.forEach((t) => {
|
withdrawals.forEach((t) => {
|
||||||
const g = gmap.get(t.inventory_id);
|
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
|
||||||
|
const g = gmap.get(gKey);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||||
} else {
|
} else {
|
||||||
g.q += Math.abs(t.quantity_number || 0);
|
g.q += Math.abs(t.quantity_number || 0);
|
||||||
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
||||||
@@ -324,6 +325,12 @@ export function DailyControlPage() {
|
|||||||
whName(data.warehouses, g.tx.warehouse_id)
|
whName(data.warehouses, g.tx.warehouse_id)
|
||||||
].filter(Boolean).join(" · ")}
|
].filter(Boolean).join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
|
{g.tx.delivered_to && (
|
||||||
|
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
|
||||||
|
<span className="text-slate-500 font-normal">تحویل به:</span>
|
||||||
|
<span>{g.tx.delivered_to}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
||||||
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||||
|
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type RowDraft } from "./StockTable";
|
||||||
|
|
||||||
|
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||||
|
|
||||||
|
interface FoundTableProps {
|
||||||
|
items: LossItem[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocal(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||||
|
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد پیداشده"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// ============================================================
|
||||||
|
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||||
|
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||||
|
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||||
|
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||||
|
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type StockRecord } from "./StockTable";
|
||||||
|
|
||||||
|
interface ProjectTransferListProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||||
|
errors: Record<string, string>;
|
||||||
|
emptyMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[600px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد انتقال</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد میشود.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد انتقال"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «اقلام ثبتشده در این نشست» برای موجودی اولیه
|
||||||
|
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظهی همان صفحه.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
export interface PurchaseSessionEntry {
|
||||||
|
material: string;
|
||||||
|
brand: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
qty: number;
|
||||||
|
unitLabel: string;
|
||||||
|
time: string; // HH:MM آمادهی نمایش
|
||||||
|
whName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseSessionLogProps {
|
||||||
|
entries: PurchaseSessionEntry[];
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
|
||||||
|
if (!entries.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||||
|
<h3 className="text-[13px] font-bold">
|
||||||
|
🧾 اقلام ثبتشده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
|
||||||
|
>
|
||||||
|
پاک کردن لیست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دسکتاپ: جدول فشرده */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 px-3">متریال</th>
|
||||||
|
<th className="py-2 px-3">برند</th>
|
||||||
|
<th className="py-2 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2 px-3">تعداد</th>
|
||||||
|
<th className="py-2 px-3">ساعت</th>
|
||||||
|
<th className="py-2 px-3">انبار</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
|
||||||
|
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.time}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: ردیفهای فشرده */}
|
||||||
|
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
|
||||||
|
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted">
|
||||||
|
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ============================================================
|
||||||
|
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||||
|
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||||
|
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||||
|
|
||||||
|
interface ReturnFilterBarProps {
|
||||||
|
preset: DatePreset;
|
||||||
|
onPreset: (p: DatePreset) => void;
|
||||||
|
customFrom: string | null;
|
||||||
|
customTo: string | null;
|
||||||
|
onCustomFrom: (iso: string | null) => void;
|
||||||
|
onCustomTo: (iso: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPreset(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||||
|
preset === p.id
|
||||||
|
? "bg-accent font-bold text-white shadow-xs"
|
||||||
|
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset === "custom" && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول برداشتهای بازه برای بخش «برگشت»
|
||||||
|
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
|
||||||
|
// برگشت محاسبهشده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
|
||||||
|
// ورودیها کلید-محورند (کلید = tx-id) تا حذف ردیفهای موفق، بقیه را
|
||||||
|
// بههم نریزد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum, fmtDateTime } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
interface ReturnTableProps {
|
||||||
|
withdrawals: InventoryTransaction[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
noteOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
onNote: (id: string, v: string) => void;
|
||||||
|
remainders: Map<string, number>;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشتها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[760px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">تاریخ</th>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">برداشت</th>
|
||||||
|
<th className="py-2.5 px-3">مانده قابل برگشت</th>
|
||||||
|
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
|
||||||
|
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">
|
||||||
|
«مانده قابل برگشت» = مقدار برداشت − برگشتهای ثبتشده همان ردیف. سقف ورودی همین مانده است.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{w.specs && Object.keys(w.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
|
||||||
|
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||||
|
<span>{fmtDateTime(w.transaction_date)}</span>
|
||||||
|
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
|
||||||
|
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="مقدار برگشت"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویرایشگر مشخصههای فنی Key-Value (موجودی اولیه)
|
||||||
|
// خروجی: jsonb استاندارد {عنوان: مقدار} همخوان با specs_hash بکاند.
|
||||||
|
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface SpecKvRow { key: string; value: string; }
|
||||||
|
|
||||||
|
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = r.key.trim();
|
||||||
|
if (k) out[k] = r.value.trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecsKvEditorProps {
|
||||||
|
rows: SpecKvRow[];
|
||||||
|
onChange: (rows: SpecKvRow[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
|
||||||
|
const update = (i: number, patch: Partial<SpecKvRow>) =>
|
||||||
|
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="عنوان مشخصه (مثلاً ضخامت)"
|
||||||
|
value={r.key}
|
||||||
|
onChange={(e) => update(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[12px] text-muted">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="مقدار (مثلاً ۲ میلیمتر)"
|
||||||
|
value={r.value}
|
||||||
|
onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
|
||||||
|
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||||
|
title="حذف این مشخصه"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange([...rows, { key: "", value: "" }])}
|
||||||
|
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||||||
|
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||||||
|
//
|
||||||
|
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
|
||||||
|
// موبایل (<640px): کارتهای عمودی لمسی با فیلد بزرگ ورود مقدار.
|
||||||
|
// هر بخش ستونهای نمایش یکسان را از اینجا میگیرد و فقط رفتار ورودی
|
||||||
|
// (تعداد ساده یا شمارششده) را با isCount انتخاب میکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface StockRecord {
|
||||||
|
id: string;
|
||||||
|
material: { id: string; name: string };
|
||||||
|
brand: { id: string; name: string } | null;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
quantity_number: number;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowDraft { key: string; qty: string; note: string; }
|
||||||
|
|
||||||
|
export function specsLine(specs: Record<string, string> | undefined): string {
|
||||||
|
return (
|
||||||
|
Object.entries(specs || {})
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(" · ") || "—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockTableProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
/** شمارش موجودی: placeholder «شمارششده» و پیشنمایش مقدار فعلی هنگام خالی بودن */
|
||||||
|
isCount?: boolean;
|
||||||
|
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است میتوان پنهان کرد */
|
||||||
|
showMaterial?: boolean;
|
||||||
|
emptyMsg: string;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qtyPlaceholder = isCount ? "شمارششده" : "۰";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[560px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
|
||||||
|
<tr className="text-right text-muted">
|
||||||
|
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
|
||||||
|
{showMaterial && (
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
)}
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتهای عمودی لمسی */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// ============================================================
|
||||||
|
// منطق بخش «برگشت» — واکشی برداشتها/برگشتها و محاسبه مانده قابل برگشت
|
||||||
|
//
|
||||||
|
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمیشود؛
|
||||||
|
// مانده هر برداشت = مقدار برداشت − مجموع برگشتهای متصل به آن.
|
||||||
|
// اتصال دو راه دارد:
|
||||||
|
// ۱) برگشتهای جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
|
||||||
|
// ۲) برگشتهای قدیمی بدون REF (ثبتشده در همان انبار مبدأ) → تخصیص FIFO
|
||||||
|
// بین برداشتهای همان ردیف انبار (متریال+برند+مشخصات+واحد).
|
||||||
|
// همهی جمع/تفریقها با round4 گرد میشوند تا خطای اعشار جاوااسکریپت
|
||||||
|
// (مثل 0.30000000000000004) در ماندهها و سقفها نشت نکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { apiV26 } from "../../../lib/api";
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
|
||||||
|
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همهی محاسبات مانده */
|
||||||
|
export function round4(n: number): number {
|
||||||
|
return Math.round(n * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تاریخ — کلید روز میلادی محلی و بازههای پیشفرض
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function gregKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d: Date, n: number): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
|
||||||
|
|
||||||
|
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
|
||||||
|
{ id: "today", label: "امروز" },
|
||||||
|
{ id: "yesterday", label: "دیروز" },
|
||||||
|
{ id: "7d", label: "۷ روز اخیر" },
|
||||||
|
{ id: "30d", label: "۳۰ روز اخیر" },
|
||||||
|
{ id: "custom", label: "بازه دلخواه" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** بازهی سرور-ساید برای هر پیشفرض؛ date_to روزِ (inclusive بکاند: < date_to + 24h) */
|
||||||
|
export function presetRange(
|
||||||
|
preset: DatePreset,
|
||||||
|
customFrom?: string,
|
||||||
|
customTo?: string,
|
||||||
|
): { date_from: string; date_to: string } {
|
||||||
|
const today = new Date();
|
||||||
|
switch (preset) {
|
||||||
|
case "today":
|
||||||
|
return { date_from: gregKey(today), date_to: gregKey(today) };
|
||||||
|
case "yesterday": {
|
||||||
|
const y = gregKey(addDays(today, -1));
|
||||||
|
return { date_from: y, date_to: y };
|
||||||
|
}
|
||||||
|
case "7d":
|
||||||
|
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
|
||||||
|
case "30d":
|
||||||
|
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
|
||||||
|
case "custom":
|
||||||
|
return {
|
||||||
|
date_from: customFrom || gregKey(today),
|
||||||
|
date_to: customTo || customFrom || gregKey(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تگ رفرنس [REF:<uuid>]
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
|
||||||
|
|
||||||
|
export function parseRef(note?: string | null): string | null {
|
||||||
|
if (!note) return null;
|
||||||
|
const m = note.match(REF_RE);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
|
||||||
|
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
|
||||||
|
const tag = `[REF:${withdrawalTxId}]`;
|
||||||
|
const note = (userNote || "").trim();
|
||||||
|
return note ? `${note} ${tag}` : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// واکشی صفحهبندیشده (هر صفحه ۱۰۰ — سقف بکاند)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const MAX_PAGES = 10; // سقف محافظهکارانه: ۱۰۰۰ رکورد
|
||||||
|
|
||||||
|
export async function fetchWithdrawalsPage(
|
||||||
|
sourceWhId: string,
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
page: number,
|
||||||
|
): Promise<{ rows: InventoryTransaction[]; total: number }> {
|
||||||
|
return apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: sourceWhId,
|
||||||
|
transaction_type: "WITHDRAWAL",
|
||||||
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی برگشتهای پروژه از ابتدای بازه (برای تطبیق REF) — صفحهبهصفحه تا سقف */
|
||||||
|
export async function fetchProjectReturns(
|
||||||
|
dateFrom: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
transaction_type: "RETURN",
|
||||||
|
date_from: dateFrom,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی تراکنشهای یک نوع در یک انبار (برای محاسبه مانده مفقودیها) */
|
||||||
|
export async function fetchAllTypeInWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
transactionType: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: warehouseId,
|
||||||
|
transaction_type: transactionType,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// مانده قابل برگشت
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتبشده) + واحد — همارز specs_hash بکاند */
|
||||||
|
function rowKey(t: InventoryTransaction): string {
|
||||||
|
const specs = Object.keys(t.specs || {})
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${String((t.specs || {})[k])}`)
|
||||||
|
.join("&");
|
||||||
|
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مانده قابل برگشت هر برداشت را حساب میکند.
|
||||||
|
* - برگشتهای REF-دار: مستقیماً از برداشت مقصد کم میشوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
|
||||||
|
* - برگشتهای REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته میشوند (به برداشت خودشان تعلق دارند).
|
||||||
|
* - برگشتهای قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشتهای همردیف تخصیص مییابند.
|
||||||
|
*/
|
||||||
|
export function computeRemainders(
|
||||||
|
withdrawals: InventoryTransaction[],
|
||||||
|
returns: InventoryTransaction[],
|
||||||
|
sourceWhId: string,
|
||||||
|
): Map<string, number> {
|
||||||
|
const rem = new Map<string, number>();
|
||||||
|
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
|
||||||
|
|
||||||
|
const inList = new Set(withdrawals.map((w) => w.id));
|
||||||
|
const legacy: InventoryTransaction[] = [];
|
||||||
|
|
||||||
|
for (const r of returns) {
|
||||||
|
const qty = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
const ref = parseRef(r.note);
|
||||||
|
if (ref) {
|
||||||
|
if (inList.has(ref)) {
|
||||||
|
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
|
||||||
|
}
|
||||||
|
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
|
||||||
|
} else {
|
||||||
|
legacy.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// تخصیص FIFO برگشتهای قدیمیِ همانبار مبدأ بین برداشتهای همردیف (قدیمی→جدید)
|
||||||
|
const buckets = new Map<string, InventoryTransaction[]>();
|
||||||
|
for (const w of withdrawals) {
|
||||||
|
const k = rowKey(w);
|
||||||
|
const list = buckets.get(k);
|
||||||
|
if (list) list.push(w);
|
||||||
|
else buckets.set(k, [w]);
|
||||||
|
}
|
||||||
|
for (const list of buckets.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const r of legacy) {
|
||||||
|
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
|
||||||
|
const list = buckets.get(rowKey(r));
|
||||||
|
if (!list) continue;
|
||||||
|
let q = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
for (const w of list) {
|
||||||
|
if (q <= 0) break;
|
||||||
|
const cur = rem.get(w.id) ?? 0;
|
||||||
|
if (cur <= 0) continue;
|
||||||
|
const take = Math.min(cur, q);
|
||||||
|
rem.set(w.id, round4(cur - take));
|
||||||
|
q = round4(q - take);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// کف صفر (محافظت در برابر دادههای ناسازگار قدیمی)
|
||||||
|
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
|
||||||
|
return rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مانده مفقودیِ باقیمانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
|
||||||
|
export function computeLossRemainders(
|
||||||
|
losses: InventoryTransaction[],
|
||||||
|
founds: InventoryTransaction[],
|
||||||
|
): { tx: InventoryTransaction; remaining: number }[] {
|
||||||
|
const foundByKey = new Map<string, number>();
|
||||||
|
for (const f of founds) {
|
||||||
|
const k = rowKey(f);
|
||||||
|
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
|
||||||
|
}
|
||||||
|
return losses
|
||||||
|
.map((tx) => {
|
||||||
|
const loss = Math.abs(tx.quantity_number || 0);
|
||||||
|
const found = foundByKey.get(rowKey(tx)) || 0;
|
||||||
|
return { tx, remaining: Math.max(0, round4(loss - found)) };
|
||||||
|
})
|
||||||
|
.filter((x) => x.remaining > 0);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||||
|
function cleanPhoneForTel(phone?: string | null): string {
|
||||||
|
if (!phone) return "";
|
||||||
|
const enDigits = phone
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
|
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||||
return suppliers.filter(
|
return suppliers.filter(
|
||||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||||
@@ -29,7 +38,11 @@ export function SupplierCard({
|
|||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head flex items-start justify-between gap-2">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
{/* فقط متن نام تأمینکننده لینک است و به کل کارت گسترش پیدا نمیکند */}
|
||||||
|
<Link
|
||||||
|
to={`/suppliers/${s.id}`}
|
||||||
|
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
|
||||||
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
@@ -49,17 +62,34 @@ export function SupplierCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||||
|
{s.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||||
|
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
|
||||||
|
title="تماس مستقیم با تأمینکننده"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
|
||||||
|
<span className="tabular-nums">{s.phone}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<span>
|
<span>
|
||||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{materials.length > 0 && (
|
{materials.length > 0 && (
|
||||||
<div className="mt-2 border-t border-line pt-1.5">
|
<div className="mt-2 border-t border-line pt-1.5">
|
||||||
<div className="mb-1 flex flex-wrap gap-1">
|
<div className="mb-1 flex flex-wrap gap-1">
|
||||||
@@ -71,6 +101,7 @@ export function SupplierCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sc-actions">
|
<div className="sc-actions">
|
||||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export interface ItemDraft {
|
export interface ItemDraft {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
@@ -51,7 +53,6 @@ export function materialOptions(
|
|||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
// متریالها سراسری هستند — مستقل از تاپیک
|
|
||||||
let list = materials;
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
@@ -67,21 +68,17 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و متریال
|
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
|
||||||
// همنام پیشنهاد میشوند.
|
|
||||||
if (cat)
|
if (cat)
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||||
);
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
|
||||||
const base = new Set<string>();
|
const base = new Set<string>();
|
||||||
apiSpecs.forEach((s) => s && base.add(s));
|
apiSpecs.forEach((s) => s && base.add(s));
|
||||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||||
@@ -90,7 +87,7 @@ export function materialOptions(
|
|||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
// مرحله ۲ — مدیریت اقلام سفارش
|
||||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
|
|
||||||
@@ -167,46 +164,83 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||||
}
|
}
|
||||||
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
|
||||||
// همان هویتِ متریال انجام میشود، نه دسته.
|
|
||||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
{/* هدر */}
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||||
|
۲
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||||
اقلام سفارش
|
اقلام سفارش
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
</div>
|
||||||
|
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
{faNum(items.length)} قلم
|
||||||
<span>متریال</span>
|
</span>
|
||||||
<span>برند</span>
|
</div>
|
||||||
<span>مشخصه</span>
|
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||||
<span>تعداد</span>
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||||
<span>واحد</span>
|
</p>
|
||||||
<span>قیمت واحد</span>
|
|
||||||
<span>قیمت کل</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-field="line_items" className="flex flex-col gap-2">
|
{/* لیست کارتهای اقلام */}
|
||||||
{items.map((it) => (
|
<div data-field="line_items" className="flex flex-col gap-3">
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
{items.map((it, idx) => (
|
||||||
|
<div
|
||||||
|
key={it.tempId}
|
||||||
|
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||||
|
>
|
||||||
|
{/* سربرگ قلم */}
|
||||||
|
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
||||||
|
{faNum(idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-ink truncate">
|
||||||
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => remove(it.tempId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">حذف</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۱: متریال و برند */}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.cat}
|
value={it.cat}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
items={allMaterialNames}
|
items={allMaterialNames}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان متریال جدید"
|
addSuffix="به عنوان متریال جدید"
|
||||||
placeholder="متریال"
|
placeholder="نام متریال"
|
||||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||||
onBlur={() => rememberField("i-cat", it)}
|
onBlur={() => rememberField("i-cat", it)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.brand}
|
value={it.brand}
|
||||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||||
@@ -216,34 +250,52 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||||
onBlur={() => rememberField("i-brand", it)}
|
onBlur={() => rememberField("i-brand", it)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۲: مشخصه فنی */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={it.specs}
|
values={it.specs}
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||||
placeholder="مشخصه"
|
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
|
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||||
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.qty}
|
value={it.qty}
|
||||||
onChange={(qty) => {
|
onChange={(qty) => {
|
||||||
patch(it.tempId, {
|
patch(it.tempId, {
|
||||||
qty,
|
qty,
|
||||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="تعداد"
|
placeholder="مقدار"
|
||||||
showWords
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.unit}
|
value={it.unit}
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان واحد جدید"
|
addSuffix="به عنوان واحد جدید"
|
||||||
placeholder="واحد"
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
onAdd={(v) => {
|
onAdd={(v) => {
|
||||||
const name = v.trim();
|
const name = v.trim();
|
||||||
@@ -257,56 +309,60 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}}
|
}}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۴: قیمت هر واحد (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||||
|
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.price}
|
value={it.price}
|
||||||
onChange={(price) => {
|
onChange={(price) => {
|
||||||
patch(it.tempId, {
|
patch(it.tempId, {
|
||||||
price,
|
price,
|
||||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت واحد"
|
placeholder="قیمت هر واحد"
|
||||||
showWords
|
showWords
|
||||||
maxDecimals={2}
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۵: قیمت کل قلم (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={it.total}
|
value={it.total}
|
||||||
onChange={(total) => {
|
onChange={(total) => {
|
||||||
patch(it.tempId, {
|
patch(it.tempId, {
|
||||||
total,
|
total,
|
||||||
price:
|
price:
|
||||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت کل"
|
placeholder="قیمت کل قلم"
|
||||||
showWords
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => remove(it.tempId)}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
{/* دکمه افزودن قلم جدید */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
||||||
onClick={add}
|
onClick={add}
|
||||||
>
|
>
|
||||||
<span className="text-base font-bold leading-none">+</span>
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن قلم جدید</span>
|
<span>افزودن قلم جدید</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,40 +9,58 @@ export interface StepperProps {
|
|||||||
onGoto: (step: number) => void;
|
onGoto: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
const STEPS = [
|
||||||
|
{ id: 1, label: "تأمینکننده" },
|
||||||
|
{ id: 2, label: "اقلام" },
|
||||||
|
{ id: 3, label: "شرایط پرداخت" },
|
||||||
|
{ id: 4, label: "پروژه" },
|
||||||
|
{ id: 5, label: "بازبینی و ثبت" },
|
||||||
|
];
|
||||||
|
|
||||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
// نوار ۵ مرحلهای ویزارد — واکنشگرا و ضدشکستگی در موبایل
|
||||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
|
||||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||||
{STEPS.map((label, i) => {
|
{STEPS.map(({ id: step, label }) => {
|
||||||
const step = i + 1;
|
|
||||||
const isCurrent = step === current;
|
const isCurrent = step === current;
|
||||||
const isComplete = completedSteps.has(step);
|
const isComplete = completedSteps.has(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={step}
|
key={step}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||||
|
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-[#2563eb] font-bold text-white"
|
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||||
: isComplete
|
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||||
? "bg-green-bg font-medium text-green"
|
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||||
: "hover:bg-slate-bg",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onGoto(step)}
|
onClick={() => onGoto(step)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
isCurrent
|
||||||
|
? "bg-white/20 text-white"
|
||||||
|
: isComplete
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||||
|
: "bg-muted/15 text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||||
|
!isCurrent && "hidden md:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -127,11 +127,9 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (_rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها ----------
|
||||||
const projectNames = useMemo(() => {
|
const projectNames = useMemo(() => {
|
||||||
const names = new Set<string>(projects.map((p) => p.name));
|
const names = new Set<string>(projects.map((p) => p.name));
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
@@ -159,7 +157,7 @@ export function WizardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- خلاصهی مرحله ۵ ----------
|
// ---------- خلاصه مرحله ۵ ----------
|
||||||
const review = useMemo(() => {
|
const review = useMemo(() => {
|
||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
@@ -202,23 +200,14 @@ export function WizardPage() {
|
|||||||
|
|
||||||
const combined = `${field} ${msg}`.toLowerCase();
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
// ۱. تأمینکننده
|
|
||||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
|
||||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
return { step: 2, field: field || "line_items" };
|
return { step: 2, field: field || "line_items" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
|
||||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
return { step: 3, field: field || "termsheets" };
|
return { step: 3, field: field || "termsheets" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۴. پروژه
|
|
||||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
|
||||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
@@ -291,10 +280,6 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
|
||||||
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
|
||||||
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
|
||||||
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
|
||||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
const matched = matchMaterial(
|
const matched = matchMaterial(
|
||||||
@@ -426,7 +411,7 @@ export function WizardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-10">
|
||||||
<Header
|
<Header
|
||||||
title="افزودن پیشنهاد جدید"
|
title="افزودن پیشنهاد جدید"
|
||||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
@@ -434,10 +419,12 @@ export function WizardPage() {
|
|||||||
backLabel="بازگشت به دفتر"
|
backLabel="بازگشت به دفتر"
|
||||||
/>
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
|
{/* نوار مراحل استپر */}
|
||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
|
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -461,12 +448,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -477,7 +464,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -491,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
@@ -506,15 +493,14 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
{/* پاورقی کنترل مراحل */}
|
||||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
|
||||||
<div>
|
<div>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToStep(currentStep - 1)}
|
onClick={() => goToStep(currentStep - 1)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<span>قبلی</span>
|
<span>قبلی</span>
|
||||||
@@ -522,21 +508,19 @@ export function WizardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
|
||||||
>
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>بعدی</span>
|
<span>بعدی</span>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -547,7 +531,7 @@ export function WizardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>ذخیره پیشنهاد</span>
|
<span>ذخیره پیشنهاد</span>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -556,21 +540,21 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
{/* دیالوگ فیلدهای خالی */}
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-[13.5px] leading-7">
|
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||||
{emptyFields.map((f) => (
|
{emptyFields.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.step}
|
key={f.step}
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
goToStep(f.step);
|
goToStep(f.step);
|
||||||
@@ -597,7 +581,7 @@ export function WizardPage() {
|
|||||||
goToStep(emptyFields[0]?.step ?? 1);
|
goToStep(emptyFields[0]?.step ?? 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
تکمیل فیلد ها
|
تکمیل فیلدها
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -786,3 +786,12 @@ button:disabled {
|
|||||||
padding: 0.45rem;
|
padding: 0.45rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* اسکرول افقی بدون نوار اسکرول (چیپهای لمسی موبایل) */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ CREATE TABLE inventory_transactions (
|
|||||||
physical_qty NUMERIC(18,6),
|
physical_qty NUMERIC(18,6),
|
||||||
transfer_pair_id UUID,
|
transfer_pair_id UUID,
|
||||||
performed_by TEXT,
|
performed_by TEXT,
|
||||||
|
delivered_to TEXT,
|
||||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -551,6 +552,35 @@ 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)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -579,6 +609,10 @@ 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)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user