Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae62a0391b | ||
|
|
7057ac4f96 | ||
|
|
36414863d4 | ||
|
|
baa32a2652 | ||
|
|
14a4a29431 | ||
|
|
d2b9af5278 | ||
|
|
e637d80b76 | ||
|
|
3c7ae80b02 | ||
|
|
de177f3520 | ||
|
|
b6cbcc30a5 | ||
|
|
08ec341c52 | ||
|
|
b5dc3e585a | ||
|
|
65758b5e78 | ||
|
|
b1edf6eed3 | ||
|
|
a181a8753d | ||
|
|
8adfd2267f |
@@ -345,6 +345,18 @@ BASE_UNITS: list[dict] = [
|
|||||||
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
||||||
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
||||||
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
||||||
|
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
|
||||||
|
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
# تبدیلهای ثابت جهانی («تبدیلهای ثابت — همه متریالها») — ضرایب استاندارد و
|
||||||
|
# فراگیر که در هر استارتآپ بهصورت idempotent ذخیره میشوند. کلید یکتا:
|
||||||
|
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
|
||||||
|
# قالبی که فرانتاند در standard_conversions مینویسد.
|
||||||
|
STANDARD_CONVERSIONS: list[dict] = [
|
||||||
|
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
|
||||||
|
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
|
||||||
|
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -379,13 +391,20 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
|||||||
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
||||||
u,
|
u,
|
||||||
)
|
)
|
||||||
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
|
# Seed the global fixed conversions (idempotent — unique key from/to unit).
|
||||||
|
for c in STANDARD_CONVERSIONS:
|
||||||
|
await run_statement(
|
||||||
|
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
|
||||||
|
"VALUES (:from_unit, :to_unit, :factor) "
|
||||||
|
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||||
await run_statement("""
|
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||||
AFTER INSERT ON supplier_materials
|
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
|
||||||
""")
|
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||||
await run_statement("""
|
await run_statement("""
|
||||||
CREATE TRIGGER trg_item_decisions_sync_price
|
CREATE TRIGGER trg_item_decisions_sync_price
|
||||||
|
|||||||
@@ -227,10 +227,16 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
|
|||||||
# pm, users) keep them — the global dependency guarantees nothing is open
|
# pm, users) keep them — the global dependency guarantees nothing is open
|
||||||
# by omission.
|
# by omission.
|
||||||
from app.routers.auth import router as _auth_router # noqa: E402
|
from app.routers.auth import router as _auth_router # noqa: E402
|
||||||
|
# The files router enforces its own access check per route: a browser cannot
|
||||||
|
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
||||||
|
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
||||||
|
from app.routers.files import router as _files_router # noqa: E402
|
||||||
|
|
||||||
for router in ROUTERS:
|
for router in ROUTERS:
|
||||||
if router is _auth_router:
|
if router is _auth_router:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX)
|
app.include_router(router, prefix=API_V1_PREFIX)
|
||||||
|
elif router is _files_router:
|
||||||
|
app.include_router(router, prefix=API_V1_PREFIX)
|
||||||
else:
|
else:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
|
|||||||
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
|
|||||||
|
|
||||||
class Material(Base):
|
class Material(Base):
|
||||||
__tablename__ = "materials"
|
__tablename__ = "materials"
|
||||||
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
|
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||||
__mapper_args__ = {"eager_defaults": True}
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
ForeignKey("material_categories.id", ondelete="RESTRICT")
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
canonical_unit_id: Mapped[int] = mapped_column(
|
canonical_unit_id: Mapped[int] = mapped_column(
|
||||||
@@ -149,7 +146,6 @@ class Material(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
|
||||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||||
back_populates="material", lazy="raise", passive_deletes=True
|
back_populates="material", lazy="raise", passive_deletes=True
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import require_role
|
from app.auth import UserContext, get_current_user, get_optional_user, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote
|
from app.models.procurement import Quote
|
||||||
from app.services import file_service
|
from app.services import file_service
|
||||||
from app.utils.errors import NotFoundError, ValidationError
|
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
|
||||||
|
from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
@@ -41,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
|
|||||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _content_disposition(file_name: str) -> str:
|
def _content_disposition(file_name: str, inline: bool = False) -> str:
|
||||||
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
|
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
|
||||||
|
|
||||||
HTTP headers are encoded as latin-1, so a Persian name raises
|
HTTP headers are encoded as latin-1, so a Persian name raises
|
||||||
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
|
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
|
||||||
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
|
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
|
||||||
in ``filename`` for old clients.
|
in ``filename`` for old clients.
|
||||||
|
|
||||||
|
``inline=True`` makes the browser render the file (PDF in a viewer,
|
||||||
|
image in the tab) instead of downloading it — required by the preview
|
||||||
|
modal, whose <iframe> would otherwise trigger a download.
|
||||||
"""
|
"""
|
||||||
raw = file_name.encode("ascii", "ignore").decode("ascii")
|
raw = file_name.encode("ascii", "ignore").decode("ascii")
|
||||||
# keep the fallback header-safe: no quotes, no control characters
|
# keep the fallback header-safe: no quotes, no control characters
|
||||||
@@ -56,9 +61,37 @@ def _content_disposition(file_name: str) -> str:
|
|||||||
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
|
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
|
||||||
if not stem or not any(ch.isalnum() for ch in stem):
|
if not stem or not any(ch.isalnum() for ch in stem):
|
||||||
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
|
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
|
||||||
# name): use a generic attachment name that keeps the extension
|
# name): use a generic name that keeps the extension
|
||||||
fallback = f"attachment{ext}" if ext else "attachment"
|
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
|
||||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
"preview" if inline else "attachment"
|
||||||
|
)
|
||||||
|
kind = "inline" if inline else "attachment"
|
||||||
|
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_file_access(
|
||||||
|
request: Request,
|
||||||
|
token: str | None,
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
kind: str,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Authorise a file read by session header OR a signed ``?t=`` token.
|
||||||
|
|
||||||
|
These two GET routes sit outside the global Bearer dependency because a
|
||||||
|
browser cannot attach an Authorization header to <iframe>/<img> loads.
|
||||||
|
A caller is accepted when it presents either a valid Casdoor session
|
||||||
|
(resolved by get_optional_user) or a signed token scoped to exactly
|
||||||
|
this file, which expires within minutes.
|
||||||
|
"""
|
||||||
|
if request is not None and request.headers.get("authorization"):
|
||||||
|
user = await get_optional_user(request, db)
|
||||||
|
if user is not None and user.role is not None:
|
||||||
|
return
|
||||||
|
if token and verify_preview_token(token, quote_id, kind, attachment_id):
|
||||||
|
return
|
||||||
|
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
||||||
@@ -92,7 +125,7 @@ async def _attachment_or_404(
|
|||||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/invoice", status_code=201)
|
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
||||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -103,29 +136,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
|||||||
return {"data": stored.to_dict()}
|
return {"data": stored.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/invoice")
|
@router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
|
||||||
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachments = await file_service.list_for(db, quote_id, "invoice")
|
attachments = await file_service.list_for(db, quote_id, "invoice")
|
||||||
return {"data": [a.to_dict() for a in attachments]}
|
return {"data": [a.to_dict() for a in attachments]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview URL — a real, short-lived URL the browser can load in an
|
||||||
|
# <iframe>/<img> (which cannot send an Authorization header). Using a real
|
||||||
|
# URL instead of a blob: avoids download managers that hijack blob URLs and
|
||||||
|
# leave 0-byte files behind.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
|
||||||
|
async def preview_url(
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
kind: str,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_ctx: UserContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if kind not in ("invoice", "proforma"):
|
||||||
|
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
|
||||||
|
await _quote_or_404(db, quote_id)
|
||||||
|
await _attachment_or_404(db, quote_id, attachment_id, kind)
|
||||||
|
token = make_preview_token(quote_id, kind, attachment_id)
|
||||||
|
# مسیر نسبی برگردانده میشود؛ فرانت با BASE_URL خودش کاملش میکند
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/invoice/{attachment_id}")
|
@router.get("/{quote_id}/invoice/{attachment_id}")
|
||||||
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def download_invoice(
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
disposition: str = "attachment",
|
||||||
|
t: str | None = None,
|
||||||
|
request: Request = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||||
|
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
|
||||||
content = await file_service.read_bytes(attachment)
|
content = await file_service.read_bytes(attachment)
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=attachment.mime_type,
|
media_type=attachment.mime_type,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
"Content-Disposition": _content_disposition(
|
||||||
|
attachment.file_name, inline=disposition == "inline"
|
||||||
|
),
|
||||||
"X-File-Id": str(attachment.id),
|
"X-File-Id": str(attachment.id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||||
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||||
@@ -137,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
|||||||
# Proforma — no status restriction
|
# Proforma — no status restriction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/proforma", status_code=201)
|
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
||||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -148,7 +219,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
|||||||
return {"data": stored.to_dict()}
|
return {"data": stored.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/proforma")
|
@router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
|
||||||
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachments = await file_service.list_for(db, quote_id, "proforma")
|
attachments = await file_service.list_for(db, quote_id, "proforma")
|
||||||
@@ -156,21 +227,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/proforma/{attachment_id}")
|
@router.get("/{quote_id}/proforma/{attachment_id}")
|
||||||
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def download_proforma(
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
disposition: str = "attachment",
|
||||||
|
t: str | None = None,
|
||||||
|
request: Request = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||||
|
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
|
||||||
content = await file_service.read_bytes(attachment)
|
content = await file_service.read_bytes(attachment)
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=attachment.mime_type,
|
media_type=attachment.mime_type,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
"Content-Disposition": _content_disposition(
|
||||||
|
attachment.file_name, inline=disposition == "inline"
|
||||||
|
),
|
||||||
"X-File-Id": str(attachment.id),
|
"X-File-Id": str(attachment.id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||||
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user, require_role
|
from app.auth import UserContext, get_optional_user, require_role
|
||||||
@@ -27,12 +27,13 @@ from app.models.inventory import (
|
|||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
)
|
)
|
||||||
from app.models.procurement import Project
|
from app.models.procurement import Project, Supplier, SupplierMaterial
|
||||||
from app.schemas.inventory import (
|
from app.schemas.inventory import (
|
||||||
InventoryTransactionCreate,
|
InventoryTransactionCreate,
|
||||||
WarehouseCreate,
|
WarehouseCreate,
|
||||||
WarehouseUpdate,
|
WarehouseUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.expand_service import category_by_material
|
||||||
from app.services.inventory_service import (
|
from app.services.inventory_service import (
|
||||||
apply_transaction,
|
apply_transaction,
|
||||||
get_warehouse_or_404,
|
get_warehouse_or_404,
|
||||||
@@ -274,12 +275,13 @@ async def update_warehouse(
|
|||||||
wh.name = payload.name
|
wh.name = payload.name
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
wh.description = payload.description
|
wh.description = payload.description
|
||||||
|
# پروژهی انبار تغییرناپذیر است — انبار برای همیشه به پروژهی والدش تعلق
|
||||||
|
# دارد و فقط با حذف و ساخت دوباره میتواند جابهجا شود.
|
||||||
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
||||||
if await db.get(Project, payload.project_id) is None:
|
raise ValidationError(
|
||||||
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
|
"پروژهی انبار قابل تغییر نیست — انبار برای همیشه به پروژهی والدش تعلق دارد",
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
field="project_id",
|
||||||
raise ForbiddenError("PM فقط میتواند انبار را در پروژه خودش جابهجا کند", field="project_id")
|
)
|
||||||
wh.project_id = payload.project_id
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||||
|
|
||||||
@@ -332,8 +334,18 @@ async def list_inventory(
|
|||||||
Warehouse.project_id == project_id
|
Warehouse.project_id == project_id
|
||||||
)
|
)
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
# v2.7: materials carry no category — match through their suppliers
|
||||||
Material.category_id == category_id
|
# (any link, mirroring GET /materials?category_id=…).
|
||||||
|
stmt = stmt.where(
|
||||||
|
exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SupplierMaterial)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(
|
||||||
|
SupplierMaterial.material_id == InventoryItem.material_id,
|
||||||
|
Supplier.category_id == category_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||||
@@ -356,10 +368,8 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
unit_ids = {i.unit_id for i in items}
|
unit_ids = {i.unit_id for i in items}
|
||||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
categories = {}
|
# v2.7: the category shown for a material comes from its first linked supplier.
|
||||||
if materials:
|
material_category = await category_by_material(db, mat_ids)
|
||||||
cat_ids = {m.category_id for m in materials.values()}
|
|
||||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
brands = {}
|
brands = {}
|
||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||||
@@ -369,7 +379,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
return [
|
return [
|
||||||
inventory_to_dict(
|
inventory_to_dict(
|
||||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id),
|
||||||
)
|
)
|
||||||
for i in items
|
for i in items
|
||||||
]
|
]
|
||||||
@@ -555,7 +565,20 @@ async def material_options(
|
|||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||||
|
|
||||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
# v2.7: a material has no category column — it belongs to a category when a
|
||||||
|
# linked supplier does (same rule as GET /materials?category_id=…).
|
||||||
|
stmt = select(Material).where(
|
||||||
|
Material.status == "active",
|
||||||
|
exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SupplierMaterial)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(
|
||||||
|
SupplierMaterial.material_id == Material.id,
|
||||||
|
Supplier.category_id == category_id,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
|
from app.models.procurement import Supplier
|
||||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
# v2.7: materials no longer carry a category, so what blocks deletion is the
|
||||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
|
||||||
|
# material of that category is reached through those suppliers.
|
||||||
|
linked = await db.scalar(
|
||||||
|
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
|
||||||
|
)
|
||||||
if linked:
|
if linked:
|
||||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
raise ConflictError(f"این دستهبندی برای {linked} تأمینکننده ثبت شده و قابل حذف نیست")
|
||||||
await db.delete(category) # category_attributes cascade via DDL
|
await db.delete(category) # category_attributes cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": {"id": str(category_id), "deleted": True}}
|
return {"data": {"id": str(category_id), "deleted": True}}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.models.catalog import (
|
|||||||
MaterialUnit,
|
MaterialUnit,
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
||||||
from app.schemas.catalog import (
|
from app.schemas.catalog import (
|
||||||
MaterialAttributeValuesPut,
|
MaterialAttributeValuesPut,
|
||||||
MaterialCreate,
|
MaterialCreate,
|
||||||
@@ -108,7 +108,16 @@ async def list_materials(
|
|||||||
):
|
):
|
||||||
stmt = select(Material)
|
stmt = select(Material)
|
||||||
if category_id:
|
if category_id:
|
||||||
stmt = stmt.where(Material.category_id == category_id)
|
sub = (
|
||||||
|
select(1)
|
||||||
|
.select_from(SupplierMaterial)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(
|
||||||
|
SupplierMaterial.material_id == Material.id,
|
||||||
|
Supplier.category_id == category_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = stmt.where(exists(sub))
|
||||||
if brand_id:
|
if brand_id:
|
||||||
# WHERE EXISTS brand_materials with that brand_id
|
# WHERE EXISTS brand_materials with that brand_id
|
||||||
sub = select(1).where(
|
sub = select(1).where(
|
||||||
|
|||||||
@@ -201,17 +201,11 @@ async def pm_on_the_way(
|
|||||||
# unit / category lookups for display
|
# unit / category lookups for display
|
||||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||||
mat_ids = {d.Material.id for d in decisions}
|
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
||||||
cats = {}
|
cats = {}
|
||||||
if mat_ids:
|
if sup_cat_ids:
|
||||||
rows = (
|
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
||||||
await db.execute(
|
cats = {c.id: c for c in rows}
|
||||||
select(Material.id, MaterialCategory)
|
|
||||||
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
|
||||||
.where(Material.id.in_(mat_ids))
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
cats = {m_id: c for m_id, c in rows}
|
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m, sup, proj in decisions:
|
for d, li, q, m, sup, proj in decisions:
|
||||||
@@ -226,7 +220,7 @@ async def pm_on_the_way(
|
|||||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name,
|
"material": {"id": str(m.id), "name": m.name,
|
||||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
|
||||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||||
"ordered_qty": str(li.quantity),
|
"ordered_qty": str(li.quantity),
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material
|
from app.models.catalog import Material
|
||||||
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
|
from app.models.procurement import (
|
||||||
|
ItemDecision,
|
||||||
|
LineItem,
|
||||||
|
LineItemProjects,
|
||||||
|
Project,
|
||||||
|
Quote,
|
||||||
|
QuoteProjects,
|
||||||
|
Supplier,
|
||||||
|
Termsheet,
|
||||||
|
)
|
||||||
from app.schemas.procurement import (
|
from app.schemas.procurement import (
|
||||||
AcceptQuoteIn,
|
AcceptQuoteIn,
|
||||||
ArriveItemsIn,
|
ArriveItemsIn,
|
||||||
@@ -39,9 +48,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
|
|||||||
def _quote_category_exists(category_id) -> exists:
|
def _quote_category_exists(category_id) -> exists:
|
||||||
return exists(
|
return exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
LineItem.quote_id == Quote.id,
|
Supplier.id == Quote.supplier_id,
|
||||||
Material.id == LineItem.material_id,
|
Supplier.category_id == category_id,
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,7 +120,23 @@ async def list_quotes(
|
|||||||
if supplier_id:
|
if supplier_id:
|
||||||
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
stmt = stmt.where(Quote.project_id == project_id)
|
# any relevant link: legacy mirror, quote-level junction, line-item
|
||||||
|
# candidates, or an accepted (decision) target — NOT just the mirror
|
||||||
|
stmt = stmt.where(
|
||||||
|
Quote.id.in_(
|
||||||
|
select(Quote.id)
|
||||||
|
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||||
|
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(
|
||||||
|
(Quote.project_id == project_id)
|
||||||
|
| (QuoteProjects.project_id == project_id)
|
||||||
|
| (LineItemProjects.project_id == project_id)
|
||||||
|
| (ItemDecision.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
if category_id:
|
if category_id:
|
||||||
stmt = stmt.where(_quote_category_exists(category_id))
|
stmt = stmt.where(_quote_category_exists(category_id))
|
||||||
if quote_date_from is not None:
|
if quote_date_from is not None:
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ async def list_supplier_materials(
|
|||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
mat_ids = {l.material_id for l in items}
|
mat_ids = {l.material_id for l in items}
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
cat_ids = {m.category_id for m in materials.values() if m.category_id}
|
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
@@ -72,8 +68,7 @@ async def create_supplier_material(
|
|||||||
async with db.begin():
|
async with db.begin():
|
||||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||||
material = await db.get(Material, payload.material_id)
|
material = await db.get(Material, payload.material_id)
|
||||||
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
|
return {"data": link_to_dict(link, material, None)}
|
||||||
return {"data": link_to_dict(link, material, category)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaterialCreate(APIModel):
|
class MaterialCreate(APIModel):
|
||||||
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
|
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
canonical_unit_id: int
|
canonical_unit_id: int
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class WarehouseUpdate(APIModel):
|
class WarehouseUpdate(APIModel):
|
||||||
|
"""Rename/re-describe a warehouse.
|
||||||
|
|
||||||
|
`project_id` is accepted only to be rejected: a warehouse belongs
|
||||||
|
permanently to its parent project and cannot be reassigned (the router
|
||||||
|
answers 400 unless the value equals the current project).
|
||||||
|
"""
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
project_id: UUID | None = None
|
project_id: UUID | None = None
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierMaterialCreate(APIModel):
|
class SupplierMaterialCreate(APIModel):
|
||||||
"""Link a global material to a supplier (derives materials.category_id)."""
|
"""Link a global material to a supplier."""
|
||||||
|
|
||||||
material_id: UUID
|
material_id: UUID
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app.models.procurement import (
|
|||||||
QuoteEvent,
|
QuoteEvent,
|
||||||
QuoteProjects,
|
QuoteProjects,
|
||||||
Supplier,
|
Supplier,
|
||||||
|
SupplierMaterial,
|
||||||
Termsheet,
|
Termsheet,
|
||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
@@ -202,14 +203,47 @@ def quote_flat(q: Quote) -> dict:
|
|||||||
# Materials
|
# Materials
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def category_by_material(db: AsyncSession, material_ids) -> dict:
|
||||||
|
"""Resolve each material's display category through its FIRST linked supplier.
|
||||||
|
|
||||||
|
v2.7 made materials category-free: ``materials.category_id`` no longer
|
||||||
|
exists and the category lives on the supplier (``suppliers.category_id``).
|
||||||
|
A material linked to several suppliers therefore has no single category of
|
||||||
|
its own — we keep the v2.5 first-link-wins rule (oldest supplier_materials
|
||||||
|
row) so the display value stays stable and matches the old derived column.
|
||||||
|
Materials with no link, or whose first supplier has no category, resolve to
|
||||||
|
nothing (the serializers emit null).
|
||||||
|
"""
|
||||||
|
ids = {mid for mid in material_ids if mid is not None}
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
links = (
|
||||||
|
await db.execute(
|
||||||
|
select(SupplierMaterial.material_id, Supplier.category_id)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(SupplierMaterial.material_id.in_(ids))
|
||||||
|
.order_by(SupplierMaterial.created_at)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
first_cat: dict = {}
|
||||||
|
for material_id, category_id in links:
|
||||||
|
if category_id is not None and material_id not in first_cat:
|
||||||
|
first_cat[material_id] = category_id
|
||||||
|
if not first_cat:
|
||||||
|
return {}
|
||||||
|
cats = (
|
||||||
|
await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(set(first_cat.values()))))
|
||||||
|
).scalars().all()
|
||||||
|
by_id = {c.id: c for c in cats}
|
||||||
|
return {mid: by_id[cid] for mid, cid in first_cat.items() if cid in by_id}
|
||||||
|
|
||||||
|
|
||||||
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
|
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
|
||||||
material_ids = [m.id for m in materials]
|
material_ids = [m.id for m in materials]
|
||||||
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
||||||
|
# Loaded regardless of expand: the non-expanded branch still returns
|
||||||
if "category" in expand:
|
# category_id, and a silent null there is the bug this replaced.
|
||||||
cat_ids = {m.category_id for m in materials}
|
maps["material_categories"] = await category_by_material(db, material_ids)
|
||||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
|
||||||
maps["categories"] = {c.id: c for c in rows}
|
|
||||||
|
|
||||||
unit_ids: set[int] = set()
|
unit_ids: set[int] = set()
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
@@ -257,10 +291,13 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
|||||||
"created_at": to_iso(m.created_at),
|
"created_at": to_iso(m.created_at),
|
||||||
"updated_at": to_iso(m.updated_at),
|
"updated_at": to_iso(m.updated_at),
|
||||||
}
|
}
|
||||||
|
# v2.7: never the material's own column (it no longer has one) — the
|
||||||
|
# category comes from the first linked supplier via _category_by_material.
|
||||||
|
material_category = maps["material_categories"].get(m.id)
|
||||||
if "category" in expand:
|
if "category" in expand:
|
||||||
d["category"] = category_brief(maps["categories"].get(m.category_id))
|
d["category"] = category_brief(material_category)
|
||||||
else:
|
else:
|
||||||
d["category_id"] = str(m.category_id)
|
d["category_id"] = str(material_category.id) if material_category else None
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
||||||
else:
|
else:
|
||||||
@@ -303,6 +340,8 @@ class _LineItemData:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.materials: dict[uuid.UUID, Material] = {}
|
self.materials: dict[uuid.UUID, Material] = {}
|
||||||
self.categories: dict[uuid.UUID, MaterialCategory] = {}
|
self.categories: dict[uuid.UUID, MaterialCategory] = {}
|
||||||
|
# material id -> its first linked supplier's category (v2.7)
|
||||||
|
self.material_category: dict[uuid.UUID, MaterialCategory] = {}
|
||||||
self.units: dict[int, Unit] = {}
|
self.units: dict[int, Unit] = {}
|
||||||
self.brands: dict[uuid.UUID, Brand] = {}
|
self.brands: dict[uuid.UUID, Brand] = {}
|
||||||
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
|
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
|
||||||
@@ -326,9 +365,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
material_ids = {li.material_id for li in line_items}
|
material_ids = {li.material_id for li in line_items}
|
||||||
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
||||||
data.materials = {m.id: m for m in materials}
|
data.materials = {m.id: m for m in materials}
|
||||||
cat_ids = {m.category_id for m in materials}
|
|
||||||
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
|
||||||
data.categories = {c.id: c for c in cats}
|
|
||||||
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
||||||
brand_ids: set[uuid.UUID] = set()
|
brand_ids: set[uuid.UUID] = set()
|
||||||
for bm in bms:
|
for bm in bms:
|
||||||
@@ -337,6 +373,7 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
|
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
|
||||||
data.brands = {b.id: b for b in brands}
|
data.brands = {b.id: b for b in brands}
|
||||||
|
data.material_category = await category_by_material(db, material_ids)
|
||||||
|
|
||||||
# Pricing always needs item_termsheets in expanded mode.
|
# Pricing always needs item_termsheets in expanded mode.
|
||||||
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
@@ -450,7 +487,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
d["material"] = {
|
d["material"] = {
|
||||||
"id": str(m.id),
|
"id": str(m.id),
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
"category": category_brief(data.categories.get(m.category_id)),
|
"category": category_brief(data.material_category.get(m.id)),
|
||||||
"brands": [
|
"brands": [
|
||||||
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
||||||
for bm in data.material_brands.get(m.id, [])
|
for bm in data.material_brands.get(m.id, [])
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from app.models.catalog import (
|
|||||||
BrandMaterial,
|
BrandMaterial,
|
||||||
Material,
|
Material,
|
||||||
MaterialAttributeValue,
|
MaterialAttributeValue,
|
||||||
MaterialCategory,
|
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
||||||
@@ -110,9 +109,7 @@ async def validate_attributes(
|
|||||||
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
||||||
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
||||||
|
|
||||||
v2.5/2.6: category_id is OPTIONAL — a global material may be created
|
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
|
||||||
without a category (it gets derived automatically from the first linked
|
|
||||||
supplier). name is GLOBALLY unique.
|
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
|
|||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||||
|
|
||||||
if payload.category_id is not None:
|
|
||||||
if await db.get(MaterialCategory, payload.category_id) is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
if not await db.get(Unit, payload.canonical_unit_id):
|
if not await db.get(Unit, payload.canonical_unit_id):
|
||||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||||
|
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=payload.category_id,
|
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
topic=payload.topic,
|
topic=payload.topic,
|
||||||
canonical_unit_id=payload.canonical_unit_id,
|
canonical_unit_id=payload.canonical_unit_id,
|
||||||
|
|||||||
@@ -918,37 +918,57 @@ async def quote_stats(
|
|||||||
|
|
||||||
|
|
||||||
def _quote_has_category(category_id: UUID):
|
def _quote_has_category(category_id: UUID):
|
||||||
"""EXISTS: quote has a line item whose material belongs to category_id."""
|
"""EXISTS: quote's supplier belongs to category_id."""
|
||||||
from app.models.catalog import Material
|
|
||||||
|
|
||||||
sub = (
|
sub = (
|
||||||
select(1)
|
select(1)
|
||||||
.where(
|
.where(
|
||||||
LineItem.quote_id == Quote.id,
|
Supplier.id == Quote.supplier_id,
|
||||||
Material.id == LineItem.material_id,
|
Supplier.category_id == category_id,
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return func.exists(sub)
|
return func.exists(sub)
|
||||||
|
|
||||||
|
|
||||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||||
"""Counts + total value per quote status for one project.
|
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||||
|
|
||||||
total_value: SUM(quantity × unit_price) over line items using the chosen
|
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
|
||||||
termsheet link first, else the first link. Rejected line items are
|
least one non-rejected line item's decision targets this project
|
||||||
excluded from value sums (contract P1-14).
|
(item_decisions.project_id). A quote merely linked to the project as a
|
||||||
|
candidate (quote-level link, line-item candidates, or accepted for a
|
||||||
|
DIFFERENT project) stays in «offered» — this project never accepted it.
|
||||||
|
|
||||||
|
total_value: SUM(quantity × unit_price) over the bucket's line items,
|
||||||
|
using the chosen termsheet link first, else the first link. Rejected
|
||||||
|
line items are excluded from value sums (contract P1-14).
|
||||||
"""
|
"""
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
# the route passes the raw path string — normalize for the Python-side
|
||||||
|
# UUID comparisons below (decision.project_id == project_id etc.)
|
||||||
|
project_id = UUID(str(project_id))
|
||||||
|
|
||||||
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
|
# --- every quote RELEVANT to this project (any kind of link) ---
|
||||||
if not quotes:
|
quote_id_rows = await db.execute(
|
||||||
|
select(Quote.id)
|
||||||
|
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||||
|
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(
|
||||||
|
(Quote.project_id == project_id)
|
||||||
|
| (QuoteProjects.project_id == project_id)
|
||||||
|
| (LineItemProjects.project_id == project_id)
|
||||||
|
| (ItemDecision.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
quote_ids = [row[0] for row in quote_id_rows.all()]
|
||||||
|
if not quote_ids:
|
||||||
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
||||||
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
||||||
|
|
||||||
quote_ids = [q.id for q in quotes]
|
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
|
||||||
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
||||||
li_ids = [li.id for li in line_items]
|
li_ids = [li.id for li in line_items]
|
||||||
|
|
||||||
@@ -956,6 +976,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
d.line_item_id: d
|
d.line_item_id: d
|
||||||
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
} if li_ids else {}
|
} if li_ids else {}
|
||||||
|
# line items explicitly linked to this project as candidates
|
||||||
|
candidate_li_ids = set(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemProjects.line_item_id).where(
|
||||||
|
LineItemProjects.line_item_id.in_(li_ids),
|
||||||
|
LineItemProjects.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
) if li_ids else set()
|
||||||
|
|
||||||
item_termsheets = (
|
item_termsheets = (
|
||||||
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
) if li_ids else []
|
) if li_ids else []
|
||||||
@@ -964,12 +996,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
for it in item_termsheets:
|
for it in item_termsheets:
|
||||||
its_by_line.setdefault(it.line_item_id, []).append(it)
|
its_by_line.setdefault(it.line_item_id, []).append(it)
|
||||||
|
|
||||||
|
def _items_of(quote: Quote) -> list[LineItem]:
|
||||||
|
return [li for li in line_items if li.quote_id == quote.id]
|
||||||
|
|
||||||
|
def _accepted_for_project(quote: Quote) -> bool:
|
||||||
|
"""≥1 non-rejected line item whose decision targets THIS project."""
|
||||||
|
return any(
|
||||||
|
(decision := decisions.get(li.id)) is not None
|
||||||
|
and decision.status == "chosen"
|
||||||
|
and decision.project_id == project_id
|
||||||
|
for li in _items_of(quote)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_any_target(quote: Quote) -> bool:
|
||||||
|
"""Whether any decision of this quote carries a target project at all
|
||||||
|
(legacy quotes accepted before the decision-project era have none —
|
||||||
|
they keep the old global-status classification)."""
|
||||||
|
return any(
|
||||||
|
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
|
||||||
|
for li in _items_of(quote)
|
||||||
|
)
|
||||||
|
|
||||||
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
||||||
for q in quotes:
|
for q in quotes:
|
||||||
per_status[q.status]["count"] += 1
|
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
|
||||||
|
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
|
||||||
|
else:
|
||||||
|
per_status[q.status]["count"] += 1
|
||||||
|
|
||||||
for li in line_items:
|
for li in line_items:
|
||||||
|
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||||
decision = decisions.get(li.id)
|
decision = decisions.get(li.id)
|
||||||
|
|
||||||
|
# --- which status bucket does this line item's value belong to? ---
|
||||||
|
accepted_here = (
|
||||||
|
decision is not None
|
||||||
|
and decision.status == "chosen"
|
||||||
|
and decision.project_id == project_id
|
||||||
|
)
|
||||||
|
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
|
||||||
|
if accepted_here:
|
||||||
|
bucket = quote.status
|
||||||
|
elif li.id in candidate_li_ids:
|
||||||
|
# the quote is still just a proposal for this project — the
|
||||||
|
# item's candidate value stays in the offered bucket
|
||||||
|
bucket = "offered"
|
||||||
|
else:
|
||||||
|
continue # this item targets another project entirely
|
||||||
|
else:
|
||||||
|
bucket = quote.status
|
||||||
|
|
||||||
if decision is not None and decision.status == "rejected":
|
if decision is not None and decision.status == "rejected":
|
||||||
continue # rejected items excluded from value sums
|
continue # rejected items excluded from value sums
|
||||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||||
@@ -979,8 +1055,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
price_it = links[0] if links else None
|
price_it = links[0] if links else None
|
||||||
if price_it is None:
|
if price_it is None:
|
||||||
continue
|
continue
|
||||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||||
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
|
|
||||||
|
|
||||||
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
||||||
for status, values in per_status.items():
|
for status, values in per_status.items():
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
|
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
|
||||||
|
|
||||||
Linking a material to a supplier derives materials.category_id from the
|
Suppliers can supply any global material across categories.
|
||||||
supplier's category when the material has none (first-link-wins). The same
|
|
||||||
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
|
|
||||||
this service applies it in the application layer so the ORM state is
|
|
||||||
consistent inside the same transaction.
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
async def link_supplier_material(
|
async def link_supplier_material(
|
||||||
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
||||||
) -> SupplierMaterial:
|
) -> SupplierMaterial:
|
||||||
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
|
"""Upsert a supplier<->material link (idempotent).
|
||||||
|
|
||||||
Caller is responsible for the surrounding transaction.
|
Caller is responsible for the surrounding transaction.
|
||||||
"""
|
"""
|
||||||
@@ -42,15 +38,11 @@ async def link_supplier_material(
|
|||||||
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
||||||
db.add(link)
|
db.add(link)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# First-link-wins: only when the material has no category yet.
|
|
||||||
if material.category_id is None:
|
|
||||||
material.category_id = supplier.category_id
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
||||||
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
|
"""Remove the link."""
|
||||||
link = await db.scalar(
|
link = await db.scalar(
|
||||||
select(SupplierMaterial).where(
|
select(SupplierMaterial).where(
|
||||||
SupplierMaterial.supplier_id == supplier_id,
|
SupplierMaterial.supplier_id == supplier_id,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Ephemeral, URL-safe file-preview tokens.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
File downloads require an ``Authorization: Bearer`` header, but a browser
|
||||||
|
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
|
||||||
|
src>`` — which is what the preview modal uses. The previous workaround
|
||||||
|
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
|
||||||
|
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
|
||||||
|
outside the originating document, and hand the browser a 0-byte file under
|
||||||
|
a bare UUID name — so the preview broke and a junk download appeared.
|
||||||
|
|
||||||
|
Instead we mint a short-lived HMAC token scoped to a single attachment and
|
||||||
|
put it in the query string, giving the iframe a *real* URL that also
|
||||||
|
carries its own authorization. The token is not a session credential: it
|
||||||
|
authorises exactly one (quote_id, kind, attachment_id) triple and expires
|
||||||
|
in minutes.
|
||||||
|
|
||||||
|
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# پیشنمایش کوتاهعمر است — فقط برای باز شدن مودال، نه اشتراکگذاری لینک
|
||||||
|
DEFAULT_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> bytes:
|
||||||
|
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
|
||||||
|
base = (
|
||||||
|
settings.CASDOOR_CLIENT_SECRET
|
||||||
|
or settings.MINIO_SECRET_KEY
|
||||||
|
or "procurement-preview-fallback"
|
||||||
|
)
|
||||||
|
return base.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
|
||||||
|
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
|
||||||
|
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def make_preview_token(
|
||||||
|
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
|
||||||
|
) -> str:
|
||||||
|
expiry = int(time.time()) + ttl
|
||||||
|
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_preview_token(
|
||||||
|
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
|
||||||
|
) -> bool:
|
||||||
|
"""True when the token is well-formed, unexpired and matches this file."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return False
|
||||||
|
expiry_raw, signature = token.split(".", 1)
|
||||||
|
try:
|
||||||
|
expiry = int(expiry_raw)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if expiry < int(time.time()):
|
||||||
|
return False
|
||||||
|
expected = _sign(quote_id, kind, attachment_id, expiry)
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
@@ -261,9 +261,7 @@ async def run() -> None:
|
|||||||
material_by_name: dict[str, Material] = {}
|
material_by_name: dict[str, Material] = {}
|
||||||
brand_material_by_material: dict[str, object] = {}
|
brand_material_by_material: dict[str, object] = {}
|
||||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||||
cat = await get_category(db, cat_slug)
|
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=cat.id,
|
|
||||||
name=name,
|
name=name,
|
||||||
canonical_unit_id=unit_ids[unit_code],
|
canonical_unit_id=unit_ids[unit_code],
|
||||||
status="active",
|
status="active",
|
||||||
@@ -289,8 +287,10 @@ async def run() -> None:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
suppliers.append(sup)
|
suppliers.append(sup)
|
||||||
# last-quoted prices: link each supplier to 2 materials of its category
|
# last-quoted prices: link each supplier to 2 materials of its category
|
||||||
|
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
|
||||||
for sup in suppliers:
|
for sup in suppliers:
|
||||||
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
|
target_slug = sup_cat_slug.get(sup.name)
|
||||||
|
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
|
||||||
for m in cat_materials[:2]:
|
for m in cat_materials[:2]:
|
||||||
db.add(SupplierMaterial(
|
db.add(SupplierMaterial(
|
||||||
supplier_id=sup.id, material_id=m.id, is_active=True,
|
supplier_id=sup.id, material_id=m.id, is_active=True,
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ services:
|
|||||||
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
|
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
|
||||||
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
|
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
|
||||||
container_name: procurement-frontend
|
container_name: procurement-frontend
|
||||||
|
# از entrypoint پیشفرض image nginx رد میشویم: آن اسکریپت `apk manifest`
|
||||||
|
# اجرا میکند و اگر دسترسی به مخزن بستهها نباشد بینهایت معلق میماند
|
||||||
|
# (سرویس بالا نمیآید و traefik خطای 502 میدهد). کانفیگ ما مستقل است و
|
||||||
|
# نیازی به آن اسکریپتها ندارد.
|
||||||
|
entrypoint: ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
|||||||
@@ -29,6 +29,26 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# MIME types: the pdf.js worker ships as .mjs, and a browser REFUSES to
|
||||||
|
# start a module worker served as application/octet-stream (nginx's
|
||||||
|
# default for unknown extensions) — which crashes the PDF preview page.
|
||||||
|
# Declared explicitly rather than relying on the system mime.types.
|
||||||
|
types {
|
||||||
|
text/javascript mjs js;
|
||||||
|
text/css css;
|
||||||
|
text/html html;
|
||||||
|
image/svg+xml svg;
|
||||||
|
image/png png;
|
||||||
|
image/jpeg jpg jpeg;
|
||||||
|
image/webp webp;
|
||||||
|
image/x-icon ico;
|
||||||
|
font/woff2 woff2;
|
||||||
|
font/woff woff;
|
||||||
|
application/json json;
|
||||||
|
application/pdf pdf;
|
||||||
|
application/wasm wasm;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback — all client-side routes go to index.html
|
# SPA fallback — all client-side routes go to index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
Generated
+263
@@ -23,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.30.0",
|
"lucide-react": "^1.30.0",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.85.0",
|
||||||
@@ -174,6 +175,256 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@openpanel/sdk": {
|
"node_modules/@openpanel/sdk": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
|
||||||
@@ -2299,6 +2550,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "4.10.38",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
|
||||||
|
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.65"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/react": "^10.0.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "3.10",
|
"@hookform/resolvers": "3.10",
|
||||||
@@ -20,11 +19,13 @@
|
|||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@sentry/react": "^10.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.30.0",
|
"lucide-react": "^1.30.0",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.85.0",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
|
openOnFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
||||||
@@ -49,6 +52,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -132,9 +136,13 @@ export function Combobox({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
|
if (!open) setOpen(true);
|
||||||
onInputChange?.(e.target.value);
|
onInputChange?.(e.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
|
if (openOnFocus) setOpen(true);
|
||||||
|
}}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
|
||||||
import type { Attachment } from "../lib/types";
|
import type { Attachment } from "../lib/types";
|
||||||
|
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
|
||||||
|
|
||||||
export interface FileListProps {
|
export interface FileListProps {
|
||||||
files: Attachment[];
|
files: Attachment[];
|
||||||
@@ -11,12 +13,25 @@ export interface FileListProps {
|
|||||||
onDelete?: (a: Attachment) => void;
|
onDelete?: (a: Attachment) => void;
|
||||||
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
||||||
onDownload?: (a: Attachment) => void | Promise<void>;
|
onDownload?: (a: Attachment) => void | Promise<void>;
|
||||||
|
// آدرس blob احراز هویتشده برای پیشنمایش. مودال پیشنمایش با objectURL
|
||||||
|
// محلی کار میکند چون <img>/<iframe> نمیتوانند توکن بفرستند.
|
||||||
|
loadPreview?: (a: Attachment) => Promise<string>;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
||||||
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
|
export function FileList({
|
||||||
|
files,
|
||||||
|
onChange,
|
||||||
|
upload,
|
||||||
|
onDelete,
|
||||||
|
onDownload,
|
||||||
|
loadPreview,
|
||||||
|
label = "افزودن فاکتور",
|
||||||
|
}: FileListProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
const remove = (i: number) => {
|
const remove = (i: number) => {
|
||||||
const removed = files[i];
|
const removed = files[i];
|
||||||
@@ -39,31 +54,108 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
|||||||
if (added.length) onChange([...files, ...added]);
|
if (added.length) onChange([...files, ...added]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// پیشنمایش: یک URL امضاشدهی کوتاهعمر میگیریم (نه blob) تا
|
||||||
|
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
|
||||||
|
const handleOpenPreview = async (inv: Attachment) => {
|
||||||
|
if (!loadPreview) return;
|
||||||
|
if (!inv.id) {
|
||||||
|
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingPreview(inv.id);
|
||||||
|
try {
|
||||||
|
const url = await loadPreview(inv);
|
||||||
|
setPreviewItem({ url, name: inv.name });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "بارگذاری پیشنمایش ناموفق بود");
|
||||||
|
} finally {
|
||||||
|
setLoadingPreview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePreview = () => setPreviewItem(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
{!files.length && (
|
||||||
{files.map((inv, i) => (
|
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
|
||||||
<div key={i} className="inv-row">
|
هنوز فاکتور یا سندی بارگذاری نشده است.
|
||||||
<span className="inv-name">📎 {inv.name}</span>
|
|
||||||
{onDownload && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inv-open"
|
|
||||||
title="دانلود"
|
|
||||||
onClick={() => {
|
|
||||||
Promise.resolve(onDownload(inv)).catch((err) =>
|
|
||||||
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{files.map((inv, i) => {
|
||||||
|
const canPreview = !!loadPreview && !!inv.id;
|
||||||
|
const isLoading = !!inv.id && loadingPreview === inv.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={inv.id || i}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
|
||||||
|
>
|
||||||
|
{/* نام و آیکون فایل — با قابلیت کلیک برای پیشنمایش */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 min-w-0 flex-1 ${canPreview ? "cursor-pointer group" : ""}`}
|
||||||
|
onClick={() => canPreview && void handleOpenPreview(inv)}
|
||||||
|
title={canPreview ? "مشاهده پیشنمایش" : inv.name}
|
||||||
|
>
|
||||||
|
{isImageFile(inv.name) ? (
|
||||||
|
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||||||
|
) : isPdfFile(inv.name) ? (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
|
||||||
|
{inv.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمههای عملیات: پیشنمایش، دانلود، حذف */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0 me-1">
|
||||||
|
{canPreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleOpenPreview(inv)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||||||
|
title="مشاهده فاکتور"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onDownload && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
Promise.resolve(onDownload(inv)).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||||||
|
title="دانلود فایل"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||||
|
title="حذف فایل"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -73,10 +165,20 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
<button
|
||||||
+ {label}
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<span>+</span>
|
||||||
|
<span>{label}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* مودال پیشنمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
|
||||||
|
{previewItem && (
|
||||||
|
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { PdfCanvas } from "./PdfCanvas";
|
||||||
|
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
export const isImageFile = (name: string = "") => {
|
||||||
|
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPdfFile = (name: string = "") => {
|
||||||
|
return /\.pdf$/i.test(name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آیا مرورگر میتواند PDF را درونخطی نشان دهد؟
|
||||||
|
*
|
||||||
|
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
|
||||||
|
* شبیهسازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
|
||||||
|
* منتهی میشود، پس آنجا pdf.js استفاده میکنیم.
|
||||||
|
*/
|
||||||
|
export function canRenderPdfNatively(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return true;
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
||||||
|
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیقترین معیار است
|
||||||
|
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
|
||||||
|
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
|
||||||
|
return !mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilePreviewModalProps {
|
||||||
|
// آدرس امضاشدهی کوتاهعمر — چون <img>/<iframe> نمیتوانند هدر توکن بفرستند
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مودال پیشنمایش فایل — مشترک بین لیست پیوستها (FileList) و کارت پیشنهاد
|
||||||
|
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم میشود (بدون
|
||||||
|
* وابستگی به پلاگین PDF مرورگر، که در شبیهسازی موبایل وجود ندارد)؛ سایر
|
||||||
|
* فرمتها فقط دکمهی دانلود دارند.
|
||||||
|
*/
|
||||||
|
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* هدر پیشنمایش */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isImageFile(name) ? (
|
||||||
|
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
||||||
|
title="باز کردن در برگه جدید"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
|
||||||
|
title="بستن"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* بدنه نمایش فایل */}
|
||||||
|
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
|
||||||
|
{isImageFile(name) ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
||||||
|
/>
|
||||||
|
) : isPdfFile(name) ? (
|
||||||
|
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را میدهد؛ در
|
||||||
|
// شبیهسازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
|
||||||
|
// canvas رسم میکند. انتخاب در زمان اجرا با تشخیص قابلیت.
|
||||||
|
canRenderPdfNatively() ? (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
title={name}
|
||||||
|
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PdfCanvas url={url} className="w-full" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
|
||||||
|
<FileText className="h-12 w-12 text-gray-400" />
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
امکان نمایش پیشنمایش زنده برای این فرمت فایل وجود ندارد.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* هوک مدیریت پیشنمایش: بارگذاری blob احراز هویتشده + آزادسازی objectURL.
|
||||||
|
* خروجی برای رندر <FilePreviewModal> آماده است.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* هوک مدیریت پیشنمایش: گرفتن URL امضاشده و نگهداشتن آن برای مودال.
|
||||||
|
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
|
||||||
|
* نیازی به آزادسازی objectURL نیست.
|
||||||
|
*/
|
||||||
|
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
|
||||||
|
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
|
||||||
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const open = async (id: string, name: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoadingId(id);
|
||||||
|
try {
|
||||||
|
const url = await load(id, name);
|
||||||
|
setItem({ url, name });
|
||||||
|
} finally {
|
||||||
|
setLoadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setItem(null);
|
||||||
|
|
||||||
|
return { item, loadingId, open, close };
|
||||||
|
}
|
||||||
@@ -12,15 +12,19 @@ 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="milestone-row">
|
<div className="flex items-start gap-3 w-full my-2">
|
||||||
<JalaliDatePicker
|
{/* کادر انتخاب تاریخ */}
|
||||||
value={milestone.when_iso}
|
<div className="w-36 shrink-0">
|
||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
<JalaliDatePicker
|
||||||
placeholder="تاریخ"
|
value={milestone.when_iso}
|
||||||
className="w-36 shrink-0"
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
disabled={disabled}
|
placeholder="تاریخ"
|
||||||
/>
|
disabled={disabled}
|
||||||
<div className="ms-amount-wrap">
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کادر ورود مبلغ و حروف زیر آن */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||||
@@ -29,8 +33,15 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف */}
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
|
||||||
|
title="حذف"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمایشگر PDF با pdf.js (canvas) بهجای <iframe>.
|
||||||
|
*
|
||||||
|
* چرا: کروم در حالت شبیهسازی موبایل (F12) پلاگین PDF درونخطی ندارد، پس
|
||||||
|
* <iframe src="...pdf"> را بهجای نمایش، دانلود میکند — که دانلودمنیجرها
|
||||||
|
* شکارش میکنند و پیشنمایش خالی میماند. با رسم روی canvas هیچ ناوبریای
|
||||||
|
* به آدرس فایل انجام نمیشود، پس نه به پلاگین مرورگر وابستهایم و نه چیزی
|
||||||
|
* برای دانلودمنیجر باقی میماند.
|
||||||
|
*/
|
||||||
|
interface PdfCanvasProps {
|
||||||
|
url: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfCanvas({ url, className }: PdfCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pageCount, setPageCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let doc: { destroy: () => void } | null = null;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// بارگذاری تنبل: pdf.js فقط وقتی پیشنمایش PDF باز میشود دانلود میشود
|
||||||
|
const pdfjs = await import("pdfjs-dist");
|
||||||
|
// فقط workerSrc ست میشود — ستکردن همزمان workerPort باعث تعارض
|
||||||
|
// میشد و بارگذاری سند در حلقه گیر میکرد («در حال بارگذاری…»).
|
||||||
|
// فایل worker با پسوند .mjs سرو میشود و nginx آن را
|
||||||
|
// text/javascript اعلام میکند (وگرنه مرورگر worker را رد میکند).
|
||||||
|
const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
|
||||||
|
// بایتها را خودمان با fetch میگیریم و به pdf.js بهصورت داده
|
||||||
|
// میدهیم، بهجای اینکه pdf.js خودش آدرس را بگیرد. دلیل: دانلودمنیجر
|
||||||
|
// (IDM) درخواست pdf.js به آدرس PDF را میرباید و با 204 برمیگرداند
|
||||||
|
// («Unexpected server response (204)»). با دادهی درونحافظه هیچ
|
||||||
|
// درخواست قابلربودنی برای دانلودمنیجر باقی نمیماند.
|
||||||
|
const res = await fetch(url, { credentials: "omit" });
|
||||||
|
if (!res.ok) throw new Error(`دریافت فایل ناموفق بود (${res.status})`);
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
|
||||||
|
const task = pdfjs.getDocument({ data: bytes });
|
||||||
|
// اگر worker/شبکه در دسترس نباشد، بهجای چرخ «در حال بارگذاری…»
|
||||||
|
// بیپایان، خطای قابلفهم نشان بده.
|
||||||
|
const loaded = await Promise.race([
|
||||||
|
task.promise,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("زمان بارگذاری PDF به پایان رسید")), 20000),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
doc = loaded as unknown as { destroy: () => void };
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setPageCount(loaded.numPages);
|
||||||
|
|
||||||
|
// فعلاً صفحهی اول — برای پیشنمایشِ سریع کافی است
|
||||||
|
const page = await loaded.getPage(1);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// مقیاس بر اساس عرض ظرف، با در نظر گرفتن devicePixelRatio برای وضوح
|
||||||
|
const containerWidth = canvas.parentElement?.clientWidth || 800;
|
||||||
|
const baseViewport = page.getViewport({ scale: 1 });
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const scale = Math.min((containerWidth - 2) / baseViewport.width, 2) * dpr;
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
canvas.width = Math.floor(viewport.width);
|
||||||
|
canvas.height = Math.floor(viewport.height);
|
||||||
|
canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(viewport.height / dpr)}px`;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(err instanceof Error ? err.message : "نمایش PDF ناموفق بود");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
doc?.destroy();
|
||||||
|
} catch {
|
||||||
|
/* نادیده بگیر */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{loading && !error && (
|
||||||
|
<div className="flex items-center justify-center p-8 text-xs text-gray-500">در حال بارگذاری…</div>
|
||||||
|
)}
|
||||||
|
{error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
|
<div className="text-xs text-gray-600">نمایش پیشنمایش ممکن نشد: {error}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<canvas ref={canvasRef} className="mx-auto block max-w-full rounded-lg border border-gray-200 bg-white shadow-xs" />
|
||||||
|
{pageCount > 1 && (
|
||||||
|
<div className="mt-2 text-center text-[11px] text-gray-500">
|
||||||
|
صفحه ۱ از {pageCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
|
import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
|
||||||
import { fmtNum } from "../lib/formatters";
|
import { fmtNum } from "../lib/formatters";
|
||||||
|
|
||||||
|
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
|
||||||
|
|
||||||
export interface StatsBarProps {
|
export interface StatsBarProps {
|
||||||
total: number;
|
total: number;
|
||||||
offered: number;
|
offered: number;
|
||||||
accepted: number;
|
accepted: number;
|
||||||
avgLeadTime: number | null; // روز
|
arrived: number;
|
||||||
|
activeFilter?: StatusFilter;
|
||||||
|
onFilterChange?: (filter: StatusFilter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
export function StatsBar({
|
||||||
|
total,
|
||||||
|
offered,
|
||||||
|
accepted,
|
||||||
|
arrived,
|
||||||
|
activeFilter = "all",
|
||||||
|
onFilterChange,
|
||||||
|
}: StatsBarProps) {
|
||||||
const stats: {
|
const stats: {
|
||||||
|
key: StatusFilter;
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
|
key: "all",
|
||||||
label: "کل پیشنهادها",
|
label: "کل پیشنهادها",
|
||||||
shortLabel: "کل",
|
shortLabel: "کل",
|
||||||
value: fmtNum(total),
|
value: fmtNum(total),
|
||||||
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "offered",
|
||||||
label: "پیشنهاد باز",
|
label: "پیشنهاد باز",
|
||||||
shortLabel: "باز",
|
shortLabel: "باز",
|
||||||
value: fmtNum(offered),
|
value: fmtNum(offered),
|
||||||
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "accepted",
|
||||||
label: "پذیرفتهشده",
|
label: "پذیرفتهشده",
|
||||||
shortLabel: "پذیرفته",
|
shortLabel: "پذیرفته",
|
||||||
value: fmtNum(accepted),
|
value: fmtNum(accepted),
|
||||||
@@ -39,48 +54,69 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "میانگین زمان تحویل",
|
key: "arrived",
|
||||||
shortLabel: "میانگین تحویل",
|
label: "تحویلشده به کارگاه",
|
||||||
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
|
shortLabel: "تحویل",
|
||||||
unit: avgLeadTime != null ? "روز" : undefined,
|
value: fmtNum(arrived),
|
||||||
color: "text-green",
|
color: "text-green",
|
||||||
icon: Timer,
|
icon: PackageCheck,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleClick = (key: StatusFilter) => {
|
||||||
|
if (!onFilterChange) return;
|
||||||
|
if (key === "all") {
|
||||||
|
onFilterChange("all");
|
||||||
|
} else {
|
||||||
|
onFilterChange(activeFilter === key ? "all" : key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => {
|
||||||
<div
|
const isClickable = Boolean(onFilterChange);
|
||||||
key={s.label}
|
const isActive = activeFilter === s.key;
|
||||||
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
|
|
||||||
>
|
|
||||||
{/* عنوان کارت */}
|
|
||||||
<div className="flex items-center justify-center sm:justify-between">
|
|
||||||
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
|
|
||||||
<span className="sm:hidden">{s.shortLabel}</span>
|
|
||||||
<span className="hidden sm:inline">{s.label}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* آیکون در دسکتاپ */}
|
return (
|
||||||
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
|
|
||||||
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* مقدار عددی به همراه واحد روز */}
|
|
||||||
<div
|
<div
|
||||||
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
key={s.label}
|
||||||
|
onClick={() => handleClick(s.key)}
|
||||||
|
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
|
||||||
|
isClickable ? "cursor-pointer select-none" : ""
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
|
||||||
|
: "border-line bg-panel hover:border-accent/40"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span>{s.value}</span>
|
{/* عنوان کارت */}
|
||||||
{s.unit && (
|
<div className="flex items-center justify-center sm:justify-between">
|
||||||
<span className="text-[10px] font-medium text-muted sm:text-xs">
|
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
|
||||||
{s.unit}
|
<span className="sm:hidden">{s.shortLabel}</span>
|
||||||
|
<span className="hidden sm:inline">{s.label}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
{/* آیکون در دسکتاپ */}
|
||||||
|
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
|
||||||
|
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مقدار عددی */}
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
||||||
|
>
|
||||||
|
<span>{s.value}</span>
|
||||||
|
{s.unit && (
|
||||||
|
<span className="text-[10px] font-medium text-muted sm:text-xs">
|
||||||
|
{s.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+61
-18
@@ -70,6 +70,20 @@ function authHeaders(init?: RequestInit): Record<string, string> {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 401 سراسری — هر پاسخ 401 یعنی نشست منقضی/نامعتبر است (JWT روی همهی
|
||||||
|
// مسیرها الزامی شد). نشست را پاک میکنیم و به صفحهی ورود میرویم تا بهجای
|
||||||
|
// «برنامهی خاموش» (استور خالی، بدون هیچ خطایی) کاربر به ورود هدایت شود.
|
||||||
|
// دخالت نمیکنیم در: خطای رمز خودِ لاگین، حالت توسعه (هدرهای X-User-*)،
|
||||||
|
// و وقتی از قبل روی /login هستیم (جلوگیری از حلقهی ریدایرکت).
|
||||||
|
function handle401(path: string, res: Response): void {
|
||||||
|
if (res.status !== 401) return;
|
||||||
|
if (path.startsWith("/auth/login")) return;
|
||||||
|
if (useAuthStore.getState().isDev) return;
|
||||||
|
if (window.location.pathname === "/login") return;
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
|
|
||||||
// پروژهی فعالِ مدیر پروژه (چند-پروژهای، 1:N): برای همهی مسیرهای پنل انبار
|
// پروژهی فعالِ مدیر پروژه (چند-پروژهای، 1:N): برای همهی مسیرهای پنل انبار
|
||||||
// صریحاً ارسال میشود تا بکاند دادههای همان پروژه را برگرداند/اعتبارسنجی کند.
|
// صریحاً ارسال میشود تا بکاند دادههای همان پروژه را برگرداند/اعتبارسنجی کند.
|
||||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||||
@@ -186,6 +200,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
|
handle401(path, res);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
let errCode: string | undefined;
|
let errCode: string | undefined;
|
||||||
@@ -261,6 +276,7 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
||||||
}
|
}
|
||||||
|
handle401(path, res);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
try {
|
try {
|
||||||
@@ -307,6 +323,9 @@ interface RawSupplier {
|
|||||||
contact_person?: string | null;
|
contact_person?: string | null;
|
||||||
tax_id?: string | null;
|
tax_id?: string | null;
|
||||||
categories?: string[] | null; // متریالهای تأمینشده
|
categories?: string[] | null; // متریالهای تأمینشده
|
||||||
|
// v2.6: هر تأمینکننده دقیقاً یک دسته دارد (FK) — بکاند این را میفرستد
|
||||||
|
category_id?: string | null;
|
||||||
|
category?: { id: string; slug: string; name: string } | null;
|
||||||
}
|
}
|
||||||
interface RawProject {
|
interface RawProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -543,7 +562,9 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
|||||||
topic:
|
topic:
|
||||||
raw.topic ||
|
raw.topic ||
|
||||||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
||||||
category: raw.category?.name || "",
|
// v2.7: دستهی متریال در بکاند از نخستین تأمینکنندهی متصل میآید
|
||||||
|
// (متریالِ بدون تأمینکننده → خالی)
|
||||||
|
supplier_category: raw.category?.name || "",
|
||||||
brand: raw.brands?.[0]?.name || "",
|
brand: raw.brands?.[0]?.name || "",
|
||||||
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
||||||
specs: (raw.attributes || [])
|
specs: (raw.attributes || [])
|
||||||
@@ -624,11 +645,15 @@ function adaptQuote(raw: RawQuote): Quote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
||||||
|
// v2.6/v2.7: هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل
|
||||||
|
// category برمیگرداند — همین اولویت اول است. categories قدیمی (ستون حذفشده)
|
||||||
|
// و دستههای استنتاجشده از پیشنهادها فقط پشتیباناند.
|
||||||
|
const own = raw.category?.name ? [raw.category.name] : [];
|
||||||
|
const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : [];
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
topic,
|
topic,
|
||||||
// دستههای ذخیرهشده روی تأمینکننده (بکاند) اولویت دارند؛ وگرنه از quotes استنتاج میشود
|
categories: own.length ? own : legacy.length ? legacy : categories,
|
||||||
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
|
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
location: raw.location || "",
|
location: raw.location || "",
|
||||||
phone: raw.phone || "",
|
phone: raw.phone || "",
|
||||||
@@ -654,20 +679,30 @@ function deriveSupplierTopics(
|
|||||||
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
||||||
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
|
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
|
||||||
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
|
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
|
||||||
if (m.category) cats.get(q.supplier_id)!.add(m.category);
|
if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rawSuppliers.map((s) => {
|
return rawSuppliers.map((s) => {
|
||||||
const t = topics.get(s.id);
|
const t = topics.get(s.id);
|
||||||
const c = cats.get(s.id);
|
const c = cats.get(s.id);
|
||||||
// اگر از quotes topic/category پیدا نشد، registry را چک کن
|
const derivedTopic = t && t.size ? [...t][0] : "";
|
||||||
if (!t || t.size === 0) {
|
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
|
||||||
|
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
||||||
|
if (!derivedTopic && !derivedCats.length) {
|
||||||
const reg = supplierTopicRegistry.get(s.id);
|
const reg = supplierTopicRegistry.get(s.id);
|
||||||
if (reg) {
|
if (reg) {
|
||||||
return adaptSupplier(s, reg.topic, reg.categories);
|
return adaptSupplier(s, reg.topic, reg.categories);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []);
|
// v2.7: متریالها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
|
||||||
|
// دستهای نمیدهد و صفحهی تأمینکنندهها بینام میشد. دستهٔ ثبتشده روی
|
||||||
|
// خودِ تأمینکننده (FK بکاند) منبع اصلی است؛ نامش با نام تاپیکها یکی است.
|
||||||
|
const backendCat = s.category?.name || "";
|
||||||
|
return adaptSupplier(
|
||||||
|
s,
|
||||||
|
derivedTopic || backendCat,
|
||||||
|
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,12 +1143,6 @@ export const api = {
|
|||||||
},
|
},
|
||||||
create: async (m: Material): Promise<Material> => {
|
create: async (m: Material): Promise<Material> => {
|
||||||
const lookup = await fetchLookup();
|
const lookup = await fetchLookup();
|
||||||
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
|
||||||
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
|
||||||
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
|
||||||
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
|
||||||
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
|
||||||
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
|
||||||
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
||||||
// (بکاند canonical_unit_id را الزامی میداند)
|
// (بکاند canonical_unit_id را الزامی میداند)
|
||||||
const unitName = (m.base_unit || "").trim();
|
const unitName = (m.base_unit || "").trim();
|
||||||
@@ -1126,7 +1155,7 @@ export const api = {
|
|||||||
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
||||||
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
||||||
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
||||||
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||||
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
||||||
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -1138,7 +1167,6 @@ export const api = {
|
|||||||
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
||||||
}
|
}
|
||||||
const raw = await postData<RawMaterial>("/materials", {
|
const raw = await postData<RawMaterial>("/materials", {
|
||||||
category_id: categoryId ?? null,
|
|
||||||
name,
|
name,
|
||||||
topic: m.topic || null,
|
topic: m.topic || null,
|
||||||
canonical_unit_id: unitId,
|
canonical_unit_id: unitId,
|
||||||
@@ -1286,13 +1314,15 @@ export const api = {
|
|||||||
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
||||||
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
|
res = await fetch(BASE_URL + dlPath, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
|
handle401(dlPath, res);
|
||||||
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1307,6 +1337,19 @@ export const api = {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// آدرس blob احراز هویتشده برای پیشنمایش (تصویر/PDF در مودال).
|
||||||
|
// تگهای <img>/<iframe>/<a href> توکن نمیفرستند، پس بایتها را با توکن
|
||||||
|
// آدرس پیشنمایش احراز هویتشده برای <iframe>/<img>.
|
||||||
|
// اینها نمیتوانند هدر Authorization بفرستند، پس بکاند یک توکن
|
||||||
|
// امضاشدهی کوتاهعمر در کوئری میدهد. عمداً blob ساخته نمیشود:
|
||||||
|
// دانلودمنیجرها (IDM) blob را شکار میکنند و فایل ۰ بایتی میسازند،
|
||||||
|
// در حالی که این یک URL واقعی است و درست بارگذاری میشود.
|
||||||
|
previewUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
|
||||||
|
const res = await getData<{ url: string; token: string }>(
|
||||||
|
`/quotes/${quoteId}/preview-url/${kind}/${id}`,
|
||||||
|
);
|
||||||
|
return BASE_URL + res.url;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- برندها (از بکاند) ----------
|
// ---------- برندها (از بکاند) ----------
|
||||||
@@ -1705,7 +1748,8 @@ export const apiV26 = {
|
|||||||
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
||||||
return rawWarehouse(d);
|
return rawWarehouse(d);
|
||||||
},
|
},
|
||||||
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
|
// project_id عمداً در تایپ نیست — انبار برای همیشه به پروژهی والدش تعلق دارد
|
||||||
|
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
|
||||||
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
||||||
return rawWarehouse(d);
|
return rawWarehouse(d);
|
||||||
},
|
},
|
||||||
@@ -1783,7 +1827,6 @@ export const apiV26 = {
|
|||||||
}): Promise<{ id: string; name: string }> =>
|
}): Promise<{ id: string; name: string }> =>
|
||||||
postData("/materials", {
|
postData("/materials", {
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
|
||||||
topic: null,
|
topic: null,
|
||||||
canonical_unit_id: opts.canonical_unit_id,
|
canonical_unit_id: opts.canonical_unit_id,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
+109
-30
@@ -17,7 +17,7 @@ export function materialById(materials: Material[], id: string): Material {
|
|||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
topic: "",
|
topic: "",
|
||||||
category: "؟",
|
supplier_category: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
specs: [],
|
specs: [],
|
||||||
base_unit: "",
|
base_unit: "",
|
||||||
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
|
|||||||
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
// specها با جداکنندهی « · »
|
// specها با جداکنندهی « . »
|
||||||
export function specsLabel(specs: string[]): string {
|
export function specsLabel(specs: string[]): string {
|
||||||
return specs.join(" · ");
|
return specs.join(" . ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* عنوان تمیز و بدون تکرار کالا:
|
* عنوان تمیز و بدون تکرار کالا:
|
||||||
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
* ترتیب اکید با جداکنندهی نقطه:
|
||||||
|
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||||
*/
|
*/
|
||||||
export function formatMaterialTitle(
|
export function formatMaterialTitle(
|
||||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
|
||||||
extraSpecs?: string[],
|
extraSpecs?: string[],
|
||||||
): string {
|
): string {
|
||||||
if (!m) return "کالا";
|
if (!m) return "کالا";
|
||||||
const rawName = (m.name || "").trim();
|
let rawName = (m.name || "").trim();
|
||||||
const rawBrand = (m.brand || "").trim();
|
const rawBrand = (m.brand || "").trim();
|
||||||
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||||
.map((s) => (s || "").trim())
|
.map((s) => (s || "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const normName = normalize(rawName);
|
|
||||||
const normBrand = normalize(rawBrand);
|
const normBrand = normalize(rawBrand);
|
||||||
|
|
||||||
if (!rawName) {
|
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمیداریم تا تکرار نشود
|
||||||
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
if (normBrand && rawName) {
|
||||||
if (rawBrand) return rawBrand;
|
const normName = normalize(rawName);
|
||||||
if (rawSpecs.length) return rawSpecs.join(" · ");
|
if (normName.startsWith(normBrand)) {
|
||||||
return m.category && m.category !== "؟" ? m.category : "کالا";
|
const rest = rawName.slice(rawBrand.length).trim();
|
||||||
|
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = rawName;
|
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
|
||||||
if (normBrand && !normName.includes(normBrand)) {
|
const parts: string[] = [];
|
||||||
title = `${rawBrand} ${title}`;
|
if (rawBrand) {
|
||||||
|
parts.push(rawBrand);
|
||||||
|
}
|
||||||
|
if (rawName) {
|
||||||
|
parts.push(rawName);
|
||||||
|
} else if (!rawBrand) {
|
||||||
|
parts.push("کالا");
|
||||||
}
|
}
|
||||||
|
|
||||||
const normTitle = normalize(title);
|
// اضافه کردن مشخصهها بدون تکرار
|
||||||
const distinctSpecs: string[] = [];
|
const normCurrent = normalize(parts.join(" "));
|
||||||
const seenNormSpecs = new Set<string>();
|
const seenNormSpecs = new Set<string>();
|
||||||
|
|
||||||
for (const s of rawSpecs) {
|
for (const s of rawSpecs) {
|
||||||
@@ -79,21 +87,76 @@ export function formatMaterialTitle(
|
|||||||
if (!ns || seenNormSpecs.has(ns)) continue;
|
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||||
seenNormSpecs.add(ns);
|
seenNormSpecs.add(ns);
|
||||||
|
|
||||||
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمیکنیم
|
||||||
if (normTitle.includes(ns)) continue;
|
if (normCurrent.includes(ns)) continue;
|
||||||
distinctSpecs.push(s);
|
parts.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distinctSpecs.length > 0) {
|
return parts.join(" . ");
|
||||||
return `${title} · ${distinctSpecs.join(" · ")}`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
|
||||||
return formatMaterialTitle(m);
|
return formatMaterialTitle(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||||
|
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
||||||
|
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||||
|
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
||||||
|
// نامِ پایه باقی بماند. دستهی همان رکوردهای قدیمی همان دستهی نخستین
|
||||||
|
// تأمینکننده است (supplier_category).
|
||||||
|
export function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
||||||
|
const fallback = (name || "").trim();
|
||||||
|
if (!fallback) return "";
|
||||||
|
let base = fallback;
|
||||||
|
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||||
|
const parts = [brand, category, ...specs]
|
||||||
|
.map((p) => (p || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!base.includes(p)) continue;
|
||||||
|
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||||
|
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||||
|
if (!next) continue;
|
||||||
|
base = next;
|
||||||
|
}
|
||||||
|
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||||
|
if (parts.some((p) => p === base)) return fallback;
|
||||||
|
return base || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
|
||||||
|
*
|
||||||
|
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
|
||||||
|
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||||
|
* برند و مشخصهی یکسان (مثل دو چمن مصنوعیِ همبرند) را میگیرد. نامِ خالی یعنی
|
||||||
|
* هویت نامعلوم — در آن حالت تطبیق نمیدهیم و متریالِ تازه ساخته میشود.
|
||||||
|
*/
|
||||||
|
export function matchMaterial(
|
||||||
|
materials: Material[],
|
||||||
|
name: string,
|
||||||
|
brand: string,
|
||||||
|
specs: string[],
|
||||||
|
): Material | undefined {
|
||||||
|
const wantName = normalize(name);
|
||||||
|
if (!wantName) return undefined;
|
||||||
|
const wantBrand = normalize(brand);
|
||||||
|
const wantSpecs = specsKey(specs);
|
||||||
|
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
|
||||||
|
const base = normalize(materialBaseName(name, brand, specs));
|
||||||
|
return (
|
||||||
|
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
|
||||||
|
materials.find(
|
||||||
|
(m) =>
|
||||||
|
base &&
|
||||||
|
normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base &&
|
||||||
|
sameIdentity(m),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||||
export function itemTermsheetIds(li: LineItem): string[] {
|
export function itemTermsheetIds(li: LineItem): string[] {
|
||||||
if (li.termsheet_ids) return li.termsheet_ids;
|
if (li.termsheet_ids) return li.termsheet_ids;
|
||||||
@@ -191,9 +254,12 @@ export function refConverted(
|
|||||||
li: LineItem,
|
li: LineItem,
|
||||||
): { qty: number; unit: string; perRef: number | null } | null {
|
): { qty: number; unit: string; perRef: number | null } | null {
|
||||||
const m = materialById(store.materials, li.material_id);
|
const m = materialById(store.materials, li.material_id);
|
||||||
const ref = refUnitForCategory(store, m.category);
|
// categoryUnits/unitConversions با «نام دسته» کلید خوردهاند؛ دستهی متریال
|
||||||
|
// همان دستهی نخستین تأمینکننده است (v2.7). متریال بدون تأمینکننده دسته
|
||||||
|
// ندارد و تبدیلش انجام نمیشود.
|
||||||
|
const ref = refUnitForCategory(store, m.supplier_category);
|
||||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
||||||
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
const conv = findConversion(store, m.supplier_category, m.brand, li.unit, ref);
|
||||||
if (!conv) return null;
|
if (!conv) return null;
|
||||||
const qty = (li.quantity || 0) * conv.factor;
|
const qty = (li.quantity || 0) * conv.factor;
|
||||||
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
||||||
@@ -281,8 +347,13 @@ export function filterQuotes(
|
|||||||
"پیشنهاد شده": "offered",
|
"پیشنهاد شده": "offered",
|
||||||
"پذیرفته شده": "accepted",
|
"پذیرفته شده": "accepted",
|
||||||
"رسیده": "arrived",
|
"رسیده": "arrived",
|
||||||
|
"باز": "offered",
|
||||||
|
"پذیرفته": "accepted",
|
||||||
|
"offered": "offered",
|
||||||
|
"accepted": "accepted",
|
||||||
|
"arrived": "arrived",
|
||||||
};
|
};
|
||||||
const stKey = STATUS_BY_LABEL[stVal] || "";
|
const stKey = STATUS_BY_LABEL[stVal] || stVal;
|
||||||
|
|
||||||
return store.quotes.filter((quote) => {
|
return store.quotes.filter((quote) => {
|
||||||
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
||||||
@@ -290,12 +361,15 @@ export function filterQuotes(
|
|||||||
if (stKey && quote.status !== stKey) return false;
|
if (stKey && quote.status !== stKey) return false;
|
||||||
|
|
||||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||||
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
// v2.7: دسته روی تأمینکننده است و بکاند هم پیشنهادها را با دستهی
|
||||||
|
// تأمینکننده فیلتر میکند — همین قاعده اینجا هم اعمال میشود تا لیست و
|
||||||
|
// فیلتر یک رفتار داشته باشند.
|
||||||
|
if (catVal && !(supObj.categories || []).includes(catVal)) return false;
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay =
|
const hay =
|
||||||
`${supObj.name} ${quote.project || ""} ` +
|
`${supObj.name} ${quote.project || ""} ` +
|
||||||
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
matInfo.map((m) => `${m.supplier_category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||||
if (!hay.toLowerCase().includes(q)) return false;
|
if (!hay.toLowerCase().includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -306,11 +380,16 @@ export function filterQuotes(
|
|||||||
export function isExpired(q: Quote): boolean {
|
export function isExpired(q: Quote): boolean {
|
||||||
return (
|
return (
|
||||||
q.status === "offered" &&
|
q.status === "offered" &&
|
||||||
|
!quoteHasArrivedOrAccepted(q) &&
|
||||||
!!q.expiry_date &&
|
!!q.expiry_date &&
|
||||||
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quoteHasArrivedOrAccepted(q: Quote): boolean {
|
||||||
|
return q.status === "accepted" || q.status === "arrived";
|
||||||
|
}
|
||||||
|
|
||||||
// آمادهسازی نمایش ترمشیت در حالت expanded
|
// آمادهسازی نمایش ترمشیت در حالت expanded
|
||||||
export function termSheetBlocks(
|
export function termSheetBlocks(
|
||||||
store: Store,
|
store: Store,
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ export interface Supplier {
|
|||||||
// ماده/کالا
|
// ماده/کالا
|
||||||
export interface Material {
|
export interface Material {
|
||||||
id: string; // "m1", "m2", ...
|
id: string; // "m1", "m2", ...
|
||||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه)
|
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
||||||
topic: string;
|
topic: string;
|
||||||
category: string; // مثلاً "میلگرد"
|
// دستهی نمایشی — از نخستین تأمینکنندهی متصل به متریال میآید (v2.7:
|
||||||
|
// خودِ متریال دیگر دسته ندارد و دسته روی تأمینکننده است). متریال بدون
|
||||||
|
// تأمینکننده = رشتهی خالی. این مقدار کلیدِ تبدیلهای واحد هم هست.
|
||||||
|
supplier_category: string;
|
||||||
brand: string; // مثلاً "ذوب آهن"
|
brand: string; // مثلاً "ذوب آهن"
|
||||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||||
base_unit: string; // واحد پایه
|
base_unit: string; // واحد پایه
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export interface AcceptModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها
|
|
||||||
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -51,13 +50,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
// تخصیص هر قلم به پروژهها (نامها)
|
|
||||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||||
// تاریخ و زمان پذیرش — پیشفرض الان
|
|
||||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
// وقتی quote باز شد، state اولیه را بساز
|
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
const offered = q.termsheets.filter((t) => t.status === "offered");
|
const offered = q.termsheets.filter((t) => t.status === "offered");
|
||||||
@@ -70,7 +66,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
});
|
});
|
||||||
setRemovedItems(rem);
|
setRemovedItems(rem);
|
||||||
setAssignments({});
|
setAssignments({});
|
||||||
// پروژهی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژهی پیشنهاد
|
|
||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||||
@@ -86,7 +81,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
// پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -109,7 +103,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const allItems = q.line_items || [];
|
const allItems = q.line_items || [];
|
||||||
const active = activeItems(q);
|
const active = activeItems(q);
|
||||||
|
|
||||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها
|
|
||||||
const projectOptions = (() => {
|
const projectOptions = (() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) => p.name && set.add(p.name));
|
projects.forEach((p) => p.name && set.add(p.name));
|
||||||
@@ -147,10 +140,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
// پچ نهایی — همهی تغییرات روی همین کلون اعمال میشود (نه روی آبجکت store)
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
// فقط کارتهایی با حداقل یک مرحلهی پرداخت
|
|
||||||
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
||||||
cards.forEach((c, idx) => {
|
cards.forEach((c, idx) => {
|
||||||
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
||||||
@@ -164,11 +155,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!cardMeta.size) {
|
if (!cardMeta.size) {
|
||||||
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
|
toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند
|
|
||||||
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;
|
||||||
});
|
});
|
||||||
@@ -178,7 +168,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند
|
|
||||||
const fallbackDom = [...cardMeta.keys()][0];
|
const fallbackDom = [...cardMeta.keys()][0];
|
||||||
const chosenDomIds = new Set<string>();
|
const chosenDomIds = new Set<string>();
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
@@ -200,7 +189,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود
|
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
const dom = assignments[li.id] || fallbackDom;
|
const dom = assignments[li.id] || fallbackDom;
|
||||||
const finalId = domToFinal[dom];
|
const finalId = domToFinal[dom];
|
||||||
@@ -210,7 +198,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
li.accepted_ts_id = finalId || null;
|
li.accepted_ts_id = finalId || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// termsheet پذیرفتهشدهای که هیچ قلمی انتخابش نکرده → برمیگردد به offered
|
|
||||||
const usedIds = new Set<string>();
|
const usedIds = new Set<string>();
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
||||||
@@ -242,197 +229,231 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||||
<DialogHeader>
|
<div className="flex flex-col max-h-[85vh] w-full">
|
||||||
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="text-[12px] text-muted">
|
{/* هدر ثابت و همتراز */}
|
||||||
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
|
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||||
</div>
|
<DialogHeader className="p-0 text-right">
|
||||||
|
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||||
|
پذیرش پیشنهاد — {sup.name}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-[13px] text-muted mt-1 text-right">
|
||||||
|
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* کارتهای termsheet */}
|
{/* بدنه اسکرولپذیر */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||||
{cards.map((c, idx) => (
|
|
||||||
<div key={c.domId} className="modal-variant">
|
{/* بخش ۱: تنظیم شرایط پرداخت */}
|
||||||
<div className="modal-variant-head">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<span className="variant-num">{cardLabel(c, idx)}</span>
|
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
|
<div className="flex flex-col gap-4">
|
||||||
×
|
{cards.map((c, idx) => (
|
||||||
</button>
|
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
|
||||||
</div>
|
<div className="modal-variant-head mb-2 flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-1.5">
|
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
|
||||||
{c.milestones.map((m, i) => (
|
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||||
<MilestoneRow
|
×
|
||||||
key={i}
|
</button>
|
||||||
milestone={m}
|
</div>
|
||||||
onChange={(nextM) => {
|
<div className="flex flex-col gap-2">
|
||||||
const ms = [...c.milestones];
|
{c.milestones.map((m, i) => (
|
||||||
ms[i] = nextM;
|
<MilestoneRow
|
||||||
updateCard(c.domId, { milestones: ms });
|
key={i}
|
||||||
}}
|
milestone={m}
|
||||||
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
|
onChange={(nextM) => {
|
||||||
/>
|
const ms = [...c.milestones];
|
||||||
|
ms[i] = nextM;
|
||||||
|
updateCard(c.domId, { milestones: ms });
|
||||||
|
}}
|
||||||
|
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
||||||
|
+ مرحله پرداخت جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
|
||||||
+ مرحله پرداخت
|
+ افزودن شرایط پرداخت جدید
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</section>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
|
|
||||||
+ نوشتن conditions جدید
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* تخصیص قلمها */}
|
{/* بخش ۲: تخصیص اقلام */}
|
||||||
<div className="border-t border-line pt-3">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به conditions:</div>
|
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
|
||||||
<div className="mb-2 text-[11.5px] text-muted">
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
{allItems.map((li) => {
|
{allItems.map((li) => {
|
||||||
const removed = removedItems.has(li.id);
|
const removed = removedItems.has(li.id);
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
<div
|
||||||
<input
|
key={li.id}
|
||||||
type="checkbox"
|
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
|
||||||
className="accent-accent"
|
>
|
||||||
checked={!removed}
|
<input
|
||||||
title="تیک = در معامله میماند"
|
type="checkbox"
|
||||||
onChange={(e) => {
|
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
|
||||||
const next = new Set(removedItems);
|
checked={!removed}
|
||||||
if (e.target.checked) next.delete(li.id);
|
title="تیک = در معامله میماند"
|
||||||
else next.add(li.id);
|
onChange={(e) => {
|
||||||
setRemovedItems(next);
|
const next = new Set(removedItems);
|
||||||
}}
|
if (e.target.checked) next.delete(li.id);
|
||||||
/>
|
else next.add(li.id);
|
||||||
<span className="flex-1 text-[13px]">
|
setRemovedItems(next);
|
||||||
{formatMaterialTitle(m)}
|
}}
|
||||||
</span>
|
/>
|
||||||
<span className="text-[12px] text-muted">
|
<div className="flex-1 min-w-[120px]">
|
||||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
<div className="text-[13.5px] font-medium text-slate-800">
|
||||||
</span>
|
{formatMaterialTitle(m)}
|
||||||
<select
|
</div>
|
||||||
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
<div className="text-[12px] text-muted mt-0.5">
|
||||||
value={assignments[li.id] || cards[0]?.domId || ""}
|
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||||
onChange={(e) => assign(li.id, e.target.value)}
|
</div>
|
||||||
>
|
</div>
|
||||||
{cards.map((c, idx) => (
|
|
||||||
<option key={c.domId} value={c.domId}>
|
|
||||||
{cardLabel(c, idx)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{active.length === 0 && (
|
|
||||||
<div className="mb-1 text-[12px] text-red">همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* تخصیص قلمها به پروژه */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-1 text-[13px] font-bold">پروژهی هر قلم:</div>
|
|
||||||
<div className="mb-2 text-[11.5px] text-muted">
|
|
||||||
برای هر متریال فقط یک پروژه انتخاب میشود — مثل شرایط پرداخت.
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{allItems
|
|
||||||
.filter((li) => !removedItems.has(li.id))
|
|
||||||
.map((li) => {
|
|
||||||
const m = materialById(materials, li.material_id);
|
|
||||||
return (
|
|
||||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
|
||||||
<div className="mb-1.5 text-[12.5px]">
|
|
||||||
<span className="font-semibold">
|
|
||||||
{formatMaterialTitle(m)}
|
|
||||||
</span>
|
|
||||||
<span className="ms-2 text-[11px] text-muted">
|
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{projectOptions.length ? (
|
|
||||||
<select
|
<select
|
||||||
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
|
||||||
value={itemProjectsSel[li.id] ?? ""}
|
value={assignments[li.id] || cards[0]?.domId || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => assign(li.id, e.target.value)}
|
||||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
disabled={removed}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
{cards.map((c, idx) => (
|
||||||
<option key={i} value="">
|
<option key={c.domId} value={c.domId}>
|
||||||
انتخاب پروژه...
|
{cardLabel(c, idx)}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{projectOptions.map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{n}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
</div>
|
||||||
<div className="text-[12px] text-muted">پروژهای ثبت نشده است.</div>
|
);
|
||||||
)}
|
})}
|
||||||
|
{active.length === 0 && (
|
||||||
|
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
|
||||||
|
<span>⚠️</span> همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
||||||
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
|
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||||
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
|
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{allItems
|
||||||
|
.filter((li) => !removedItems.has(li.id))
|
||||||
|
.map((li) => {
|
||||||
|
const m = materialById(materials, li.material_id);
|
||||||
|
return (
|
||||||
|
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
||||||
|
{formatMaterialTitle(m)}
|
||||||
|
</span>
|
||||||
|
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||||
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{projectOptions.length ? (
|
||||||
|
<select
|
||||||
|
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
value={itemProjectsSel[li.id] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
||||||
|
<option key={i} value="">
|
||||||
|
انتخاب پروژه...
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{projectOptions.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||||
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
|
||||||
|
<FileList
|
||||||
|
files={invoices}
|
||||||
|
onChange={setInvoices}
|
||||||
|
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||||
|
onDelete={(a) =>
|
||||||
|
a.id &&
|
||||||
|
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
||||||
|
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-line w-full"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیشفاکتورها</h3>
|
||||||
|
<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 className="h-px bg-line w-full"></div>
|
||||||
|
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
|
||||||
|
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* فوتر ثابت */}
|
||||||
|
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||||
|
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||||
|
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||||
|
ثبت معامله بستهشده
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فاکتورهای اصلی */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
|
||||||
<FileList
|
|
||||||
files={invoices}
|
|
||||||
onChange={setInvoices}
|
|
||||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
|
||||||
onDelete={(a) =>
|
|
||||||
a.id &&
|
|
||||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
|
||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* پیشفاکتورها */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</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)}
|
|
||||||
label="افزودن پیشفاکتور"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* تاریخ و زمان پذیرش */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="field max-w-xs">
|
|
||||||
<label>تاریخ و زمان پذیرش</label>
|
|
||||||
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="slate" onClick={onClose}>
|
|
||||||
انصراف
|
|
||||||
</Button>
|
|
||||||
<Button onClick={confirm}>ثبت معامله بستهشده</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { filterQuotes } from "../../lib/quoteUtils";
|
import { filterQuotes } from "../../lib/quoteUtils";
|
||||||
import { daysBetween } from "../../lib/formatters";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { StatsBar } from "../../components/StatsBar";
|
import { StatsBar, type StatusFilter } from "../../components/StatsBar";
|
||||||
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||||
import { LedgerTable } from "./LedgerTable";
|
import { LedgerTable } from "./LedgerTable";
|
||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
@@ -56,19 +55,37 @@ export function DashboardPage() {
|
|||||||
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||||
}, [store, filters, sortDir]);
|
}, [store, filters, sortDir]);
|
||||||
|
|
||||||
|
// شمارش تفکیکی پیشنهادها بر اساس پایپلاین خرید
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = quotes.length;
|
const total = quotes.length;
|
||||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||||
const leadTimes = quotes
|
const arrived = quotes.filter((q) => q.status === "arrived").length;
|
||||||
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
return { total, offered, accepted, arrived };
|
||||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
|
||||||
.filter((x): x is number => x != null);
|
|
||||||
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
|
|
||||||
return { total, offered, accepted, avgLead };
|
|
||||||
}, [quotes]);
|
}, [quotes]);
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
// وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
|
||||||
|
const activeStatusFilter: StatusFilter = useMemo(() => {
|
||||||
|
if (filters.status === "offered") return "offered";
|
||||||
|
if (filters.status === "accepted") return "accepted";
|
||||||
|
if (filters.status === "arrived") return "arrived";
|
||||||
|
return "all";
|
||||||
|
}, [filters.status]);
|
||||||
|
|
||||||
|
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
|
||||||
|
const handleStatusFilterChange = (status: StatusFilter) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: status === "all" ? "" : status,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// دستهها روی تأمینکننده هستند (v2.7) و بکاند هم فیلتر پیشنهادها را با
|
||||||
|
// دستهی تأمینکننده انجام میدهد — پس گزینههای فیلتر از همانجا میآید.
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => [...new Set(suppliers.flatMap((s) => s.categories || []).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fa")),
|
||||||
|
[suppliers],
|
||||||
|
);
|
||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,7 +136,9 @@ export function DashboardPage() {
|
|||||||
total={stats.total}
|
total={stats.total}
|
||||||
offered={stats.offered}
|
offered={stats.offered}
|
||||||
accepted={stats.accepted}
|
accepted={stats.accepted}
|
||||||
avgLeadTime={stats.avgLead}
|
arrived={stats.arrived}
|
||||||
|
activeFilter={activeStatusFilter}
|
||||||
|
onFilterChange={handleStatusFilterChange}
|
||||||
/>
|
/>
|
||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -10,8 +9,33 @@ export interface Filters {
|
|||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// لیست وضعیتها از dictionary واحد (constants) — نه آرایهی تکراری
|
// گزینههای استاندارد و یکپارچه وضعیت هماهنگ با کارتهای بالای صفحه
|
||||||
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
|
const STATUS_OPTIONS = [
|
||||||
|
{ id: "offered", label: "پیشنهاد باز" },
|
||||||
|
{ id: "accepted", label: "پذیرفتهشده" },
|
||||||
|
{ id: "arrived", label: "تحویلشده" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, string> = {
|
||||||
|
offered: "پیشنهاد باز",
|
||||||
|
accepted: "پذیرفتهشده",
|
||||||
|
arrived: "تحویلشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TO_KEY: Record<string, string> = {
|
||||||
|
"پیشنهاد باز": "offered",
|
||||||
|
"پذیرفتهشده": "accepted",
|
||||||
|
"تحویلشده": "arrived",
|
||||||
|
"پیشنهاد شده": "offered",
|
||||||
|
"پذیرفته شده": "accepted",
|
||||||
|
"رسیده": "arrived",
|
||||||
|
"باز": "offered",
|
||||||
|
"پذیرفته": "accepted",
|
||||||
|
"تحویل": "arrived",
|
||||||
|
offered: "offered",
|
||||||
|
accepted: "accepted",
|
||||||
|
arrived: "arrived",
|
||||||
|
};
|
||||||
|
|
||||||
export interface FilterToolbarProps {
|
export interface FilterToolbarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
@@ -40,6 +64,14 @@ export function FilterToolbar({
|
|||||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
onChange({ ...filters, category: "", supplier: "", status: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
||||||
|
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
|
||||||
|
|
||||||
|
const handleStatusChange = (val: string) => {
|
||||||
|
const key = STATUS_TO_KEY[val] || val;
|
||||||
|
set({ status: key });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||||
@@ -148,12 +180,12 @@ export function FilterToolbar({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.status}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_OPTIONS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه وضعیتها"
|
placeholder="همه وضعیتها"
|
||||||
onInputChange={(v) => set({ status: v })}
|
onInputChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,12 +229,12 @@ export function FilterToolbar({
|
|||||||
<div className="field min-w-[150px] flex-1">
|
<div className="field min-w-[150px] flex-1">
|
||||||
<label>وضعیت</label>
|
<label>وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.status}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_OPTIONS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه"
|
placeholder="همه"
|
||||||
onInputChange={(v) => set({ status: v })}
|
onInputChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field min-w-[200px] flex-1">
|
<div className="field min-w-[200px] flex-1">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
|
|||||||
import type { Quote, Store } from "../../lib/types";
|
import type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
isExpired,
|
isExpired,
|
||||||
itemsSummary,
|
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
projectCell,
|
projectCell,
|
||||||
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
const pc = priceCell(total);
|
const pc = priceCell(total);
|
||||||
const ts = termsheetSummary(store, q);
|
const ts = termsheetSummary(store, q);
|
||||||
const unitWarns = unitWarning(store, q);
|
const unitWarns = unitWarning(store, q);
|
||||||
const summary = itemsSummary(store, q);
|
|
||||||
|
|
||||||
// ستون / دکمه پذیرش
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
{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">
|
||||||
<Link
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
<Link
|
||||||
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
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]"
|
||||||
{sup.name}
|
>
|
||||||
</Link>
|
{sup.name}
|
||||||
|
</Link>
|
||||||
|
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
||||||
|
q.is_official
|
||||||
|
? "bg-green-bg text-green"
|
||||||
|
: "bg-slate-bg text-muted",
|
||||||
|
)}
|
||||||
|
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
||||||
|
>
|
||||||
|
{q.is_official ? "رسمی" : "غیررسمی"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</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 sm:gap-1.5">
|
||||||
@@ -188,47 +200,22 @@ 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">
|
||||||
{summary.count > 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.5">
|
{activeItems.length > 0 ? (
|
||||||
<span className="font-bold text-xs text-ink sm:text-sm">
|
activeItems.map((li) => {
|
||||||
{fmtNum(summary.count)} قلم کالا
|
const m = materialById(store.materials, li.material_id);
|
||||||
</span>
|
return (
|
||||||
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
<span key={li.id} className="break-words">
|
||||||
{store.quotes
|
{materialLabel(m)}
|
||||||
.find((x) => x.id === q.id)
|
|
||||||
?.line_items?.filter((li) => !li.rejected)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((li) => {
|
|
||||||
const m = materialById(store.materials, li.material_id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={li.id}
|
|
||||||
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
|
||||||
• {materialLabel(m)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
|
||||||
{fmtNum(li.quantity)}{" "}
|
|
||||||
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(q.line_items?.length || 0) > 3 && (
|
|
||||||
<span className="pt-0.5 text-[10px] text-muted">
|
|
||||||
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
</div>
|
})
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<span>—</span>
|
||||||
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
)}
|
||||||
<span className="break-words">{summary.text}</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unitWarns.map((w) => (
|
{unitWarns.map((w) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -243,32 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="پروژه">
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
||||||
<span className="truncate">
|
|
||||||
{(() => {
|
|
||||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
|
||||||
if (q.status === "accepted" || q.status === "arrived") {
|
|
||||||
// فقط پروژهی هدف پذیرفتهشده — همان منطق itemProjects در وضعیت accepted
|
|
||||||
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
|
|
||||||
return targetProj?.name ?? projectCell(q);
|
|
||||||
}
|
|
||||||
// وضعیت offered: همهی پروژههای کاندیدا از line_items (همان منطق itemProjects)
|
|
||||||
const names = new Set<string>();
|
|
||||||
activeItems.forEach((li) =>
|
|
||||||
(li.projects || []).forEach((p) => {
|
|
||||||
const name = typeof p === "string" ? p : p.name;
|
|
||||||
if (name) names.add(name);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (names.size > 0) return [...names].join("، ");
|
|
||||||
return projectCell(q);
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</InfoCell>
|
|
||||||
|
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
|||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||||
import { itemProjects } from "../../lib/quoteUtils";
|
import { itemProjects } from "../../lib/quoteUtils";
|
||||||
|
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
||||||
|
|
||||||
export interface QuoteDetailProps {
|
export interface QuoteDetailProps {
|
||||||
store: Store;
|
store: Store;
|
||||||
@@ -25,6 +26,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||||
const [bump, setBump] = useState(0);
|
const [bump, setBump] = useState(0);
|
||||||
|
|
||||||
|
// پیشنمایش پیوستها: کلیک روی نام فایل، فایل را نشان میدهد (نه دانلود).
|
||||||
|
// بایتها با توکن گرفته میشوند چون <img>/<iframe> نمیتوانند هدر بفرستند.
|
||||||
|
// کلید با پیشوند نوع پیوست ساخته میشود تا نوعش گم نشود.
|
||||||
|
const preview = useFilePreview((key, name) => {
|
||||||
|
const [kind, id] = key.split(":") as ["invoice" | "proforma", string];
|
||||||
|
void name;
|
||||||
|
return api.files.previewUrl(q.id, kind, id);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
||||||
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
||||||
@@ -144,31 +154,35 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
attachments.proformas.forEach((pf, i) => {
|
attachments.proformas.forEach((pf, i) => {
|
||||||
if (!pf.id) return;
|
if (!pf.id) return;
|
||||||
attachParts.push(
|
attachParts.push(
|
||||||
<button
|
<span key={"pf" + i} className="inline-flex items-center">
|
||||||
key={"pf" + i}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="detail-link"
|
className="detail-link"
|
||||||
title="دانلود پیشفاکتور"
|
title="مشاهده پیشفاکتور"
|
||||||
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
disabled={preview.loadingId === `proforma:${pf.id}`}
|
||||||
>
|
onClick={() => void preview.open(`proforma:${pf.id}`, pf.name)}
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
>
|
||||||
پیشفاکتور: {pf.name}
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
</button>,
|
پیشفاکتور: {pf.name}
|
||||||
|
</button>
|
||||||
|
</span>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
attachments.invoices.forEach((inv, i) => {
|
attachments.invoices.forEach((inv, i) => {
|
||||||
if (!inv.id) return;
|
if (!inv.id) return;
|
||||||
attachParts.push(
|
attachParts.push(
|
||||||
<button
|
<span key={"inv" + i} className="inline-flex items-center">
|
||||||
key={"inv" + i}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="detail-link"
|
className="detail-link"
|
||||||
title="دانلود فاکتور"
|
title="مشاهده فاکتور"
|
||||||
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
disabled={preview.loadingId === `invoice:${inv.id}`}
|
||||||
>
|
onClick={() => void preview.open(`invoice:${inv.id}`, inv.name)}
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
>
|
||||||
فاکتور اصلی: {inv.name}
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
</button>,
|
فاکتور اصلی: {inv.name}
|
||||||
|
</button>
|
||||||
|
</span>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,6 +265,9 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{preview.item && (
|
||||||
|
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,16 +148,8 @@ export function OrganizationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveWarehouse = async (id: string, projectId: string) => {
|
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
||||||
try {
|
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
||||||
await apiV26.warehouses.update(id, { project_id: projectId });
|
|
||||||
toast.success("انبار منتقل شد ✓");
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeWarehouse = async (id: string) => {
|
const removeWarehouse = async (id: string) => {
|
||||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||||
try {
|
try {
|
||||||
@@ -329,16 +321,6 @@ export function OrganizationPage() {
|
|||||||
>
|
>
|
||||||
📊 داشبورد
|
📊 داشبورد
|
||||||
</button>
|
</button>
|
||||||
<select
|
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
|
||||||
value={w.project_id}
|
|
||||||
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
|
|
||||||
title="انتقال به پروژه دیگر"
|
|
||||||
>
|
|
||||||
{data.lookup.projects.map((p2) => (
|
|
||||||
<option key={p2.id} value={p2.id}>{p2.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import {
|
import {
|
||||||
activeItems,
|
|
||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
@@ -29,25 +28,45 @@ import { EmptyState } from "../../components/EmptyState";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||||
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
|
return quotes.filter(
|
||||||
|
(q) =>
|
||||||
|
(q.projects || []).includes(name) ||
|
||||||
|
q.project === name ||
|
||||||
|
(q.line_items || []).some(
|
||||||
|
(li) =>
|
||||||
|
li.target_project?.name === name ||
|
||||||
|
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اقلام مربوط به این پروژه در یک quote
|
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
||||||
|
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
||||||
|
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
||||||
|
// برای این پروژه همچنان «پیشنهاد شده» است.
|
||||||
|
function acceptedForProject(q: Quote, name: string): boolean {
|
||||||
|
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
||||||
|
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
||||||
|
function hasTargets(q: Quote): boolean {
|
||||||
|
return (q.line_items || []).some((li) => !!li.target_project);
|
||||||
|
}
|
||||||
|
|
||||||
|
// اقلام مربوط به این پروژه در یک quote:
|
||||||
|
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
||||||
|
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
||||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
|
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||||
|
if (targeted.length) return targeted;
|
||||||
if (items.some((li) => li.projects && li.projects.length)) {
|
if (items.some((li) => li.projects && li.projects.length)) {
|
||||||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function qTotal(q: Quote): number {
|
|
||||||
return activeItems(q).reduce((sum, li) => {
|
|
||||||
const t = lineItemTotal(li);
|
|
||||||
return sum + (t || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysSince(iso: string | null): number | null {
|
function daysSince(iso: string | null): number | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -147,10 +166,11 @@ export function ProjectsPage() {
|
|||||||
// ---------- پنل جزئیات یک پروژه ----------
|
// ---------- پنل جزئیات یک پروژه ----------
|
||||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||||
const quotes = projectQuotes(store.quotes, name);
|
const quotes = projectQuotes(store.quotes, name);
|
||||||
const sumBy = (st: Quote["status"]) =>
|
|
||||||
quotes
|
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
||||||
.filter((q) => q.status === st)
|
const itemsTotal = (q: Quote) =>
|
||||||
.reduce((s, q) => s + qTotal(q), 0);
|
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||||
|
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||||
|
|
||||||
const matName = (mid: string) => {
|
const matName = (mid: string) => {
|
||||||
const m = materialById(store.materials, mid);
|
const m = materialById(store.materials, mid);
|
||||||
@@ -184,11 +204,19 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// دستهبندی ایزوله per-project:
|
||||||
|
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
||||||
|
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
||||||
|
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
||||||
const offered = quotes
|
const offered = quotes
|
||||||
.filter((q) => q.status === "offered")
|
.filter(
|
||||||
|
(q) =>
|
||||||
|
q.status === "offered" ||
|
||||||
|
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
|
||||||
|
)
|
||||||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted");
|
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
|
||||||
const arrived = quotes.filter((q) => q.status === "arrived");
|
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
|
||||||
|
|
||||||
const offeredRow = (q: Quote) => {
|
const offeredRow = (q: Quote) => {
|
||||||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||||||
@@ -199,7 +227,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
جمع: <b>{faNum(qTotal(q))} تومان</b>
|
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||||||
@@ -240,7 +268,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
هزینه: <b>{faNum(qTotal(q))} تومان</b>
|
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,13 +283,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||||||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||||||
ارزش باز: {faNum(sumBy("offered"))} تومان
|
ارزش باز: {faNum(sumBy(offered))} تومان
|
||||||
</span>
|
</span>
|
||||||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||||||
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
|
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
|
||||||
</span>
|
</span>
|
||||||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||||||
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
|
ارزش رسیده: {faNum(sumBy(arrived))} تومان
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
|
|||||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { topicByName } from "../../lib/topics";
|
||||||
|
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 { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
// (سلسلهمراتب دامنه: دسته → تأمینکننده؛ در v2.6 دسته = تاپیک)
|
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const { category: catParam = "" } = useParams();
|
const { category: catParam = "" } = useParams();
|
||||||
@@ -36,12 +36,29 @@ export function CategoryPage() {
|
|||||||
contact: "",
|
contact: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
// همهی تأمینکنندهها برای انتخاب سریع
|
||||||
const supplierOptions = useMemo(
|
const supplierOptions = useMemo(
|
||||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||||
[suppliers],
|
[suppliers],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// محاسبه میانگین زمان تحویل اختصاصی هر تأمینکننده بر اساس سفارشهای تحویلشده
|
||||||
|
const supLeadTimes = useMemo(() => {
|
||||||
|
const map = new Map<string, number | null>();
|
||||||
|
suppliers.forEach((s) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||||
|
map.set(s.id, avg);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [quotes, suppliers]);
|
||||||
|
|
||||||
if (!cat) return <Navigate to="/suppliers" replace />;
|
if (!cat) return <Navigate to="/suppliers" replace />;
|
||||||
|
|
||||||
const sups = suppliersOfCategory(cat, cat, suppliers);
|
const sups = suppliersOfCategory(cat, cat, suppliers);
|
||||||
@@ -69,7 +86,6 @@ export function CategoryPage() {
|
|||||||
toast.error("نام تأمینکننده را وارد کنید.");
|
toast.error("نام تأمینکننده را وارد کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// تأمینکننده دقیقاً زیر همین دسته ثبت میشود (۱ دسته به ازای هر تأمینکننده)
|
|
||||||
const s: Supplier = {
|
const s: Supplier = {
|
||||||
id: nextId("s", suppliers),
|
id: nextId("s", suppliers),
|
||||||
topic: cat,
|
topic: cat,
|
||||||
@@ -101,7 +117,15 @@ export function CategoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{sups.map((s) => (
|
{sups.map((s) => (
|
||||||
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
<SupplierCard
|
||||||
|
key={s.id}
|
||||||
|
s={s}
|
||||||
|
topic={cat}
|
||||||
|
deletable
|
||||||
|
onDelete={removeSup}
|
||||||
|
materials={supMats.get(s.id) || []}
|
||||||
|
avgLeadTime={supLeadTimes.get(s.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ export function SupplierDetailPage() {
|
|||||||
(q.line_items || []).forEach((li) => {
|
(q.line_items || []).forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const m = materials.find((x) => x.id === li.material_id);
|
const m = materials.find((x) => x.id === li.material_id);
|
||||||
if (!m || !m.category) return;
|
// دستهی نمایشی متریال = دستهی نخستین تأمینکنندهی متصل (v2.7)
|
||||||
out.add(m.category);
|
if (!m || !m.supplier_category) return;
|
||||||
|
out.add(m.supplier_category);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
import { fmtNum } from "../../lib/formatters";
|
||||||
const out = new Set<string>();
|
|
||||||
materials
|
|
||||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
|
||||||
.forEach((m) => out.add(m.category));
|
|
||||||
suppliers
|
|
||||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
|
||||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
@@ -19,32 +10,44 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت تأمینکننده
|
// کارت تأمینکننده همراه با برچسب میانگین زمان تحویل
|
||||||
export function SupplierCard({
|
export function SupplierCard({
|
||||||
s,
|
s,
|
||||||
topic,
|
topic,
|
||||||
deletable,
|
deletable,
|
||||||
onDelete,
|
onDelete,
|
||||||
materials = [],
|
materials = [],
|
||||||
|
avgLeadTime,
|
||||||
}: {
|
}: {
|
||||||
s: Supplier;
|
s: Supplier;
|
||||||
topic: string;
|
topic: string;
|
||||||
deletable?: boolean;
|
deletable?: boolean;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
|
||||||
materials?: string[];
|
materials?: string[];
|
||||||
|
avgLeadTime?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
{deletable && onDelete && (
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
{avgLeadTime != null && (
|
||||||
×
|
<span
|
||||||
</button>
|
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
|
||||||
)}
|
title="میانگین زمان تحویل سفارشها"
|
||||||
|
>
|
||||||
|
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
|
||||||
|
<span>{fmtNum(avgLeadTime)} روز</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deletable && onDelete && (
|
||||||
|
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ export function UnitsPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{g.base}</span>
|
<span className="whitespace-normal break-words text-start">{g.base}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -519,7 +519,7 @@ export function UnitsPage() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setMaterialId(v.id)}
|
onClick={() => setMaterialId(v.id)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{v.label}</span>
|
<span className="whitespace-normal break-words text-start">{v.label}</span>
|
||||||
<span className="ut-ref">{v.refUnit || "—"}</span>
|
<span className="ut-ref">{v.refUnit || "—"}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useSuppliers } from "../../hooks";
|
import { useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
|
||||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
|
||||||
const out = new Set<string>();
|
|
||||||
materials
|
|
||||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
|
||||||
.forEach((m) => out.add(m.category));
|
|
||||||
suppliers
|
|
||||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
|
||||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
@@ -41,43 +41,18 @@ export function itemRowLabel(it: ItemDraft): string {
|
|||||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
|
||||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
|
|
||||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
|
||||||
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
|
||||||
// نامِ پایه باقی بماند.
|
|
||||||
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
|
||||||
const fallback = (name || "").trim();
|
|
||||||
if (!fallback) return "";
|
|
||||||
let base = fallback;
|
|
||||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
|
||||||
const parts = [brand, category, ...specs]
|
|
||||||
.map((p) => (p || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((a, b) => b.length - a.length);
|
|
||||||
for (const p of parts) {
|
|
||||||
if (!base.includes(p)) continue;
|
|
||||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
|
||||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
|
||||||
if (!next) continue;
|
|
||||||
base = next;
|
|
||||||
}
|
|
||||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
|
||||||
if (parts.some((p) => p === base)) return fallback;
|
|
||||||
return base || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
|
||||||
export function materialOptions(
|
export function materialOptions(
|
||||||
materials: Material[],
|
materials: Material[],
|
||||||
topic: string,
|
_topic: string,
|
||||||
item: ItemDraft,
|
item: ItemDraft,
|
||||||
field: "cat" | "brand" | "spec",
|
field: "brand" | "spec",
|
||||||
allItems: ItemDraft[] = [],
|
allItems: ItemDraft[] = [],
|
||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
// متریالها سراسری هستند — مستقل از تاپیک
|
||||||
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||||
@@ -91,19 +66,19 @@ export function materialOptions(
|
|||||||
return [...out];
|
return [...out];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (field === "cat") {
|
|
||||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
|
||||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و دسته
|
// برند سراسری است — مستقل از تاپیک و متریال
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
||||||
|
// همنام پیشنهاد میشوند.
|
||||||
|
if (cat)
|
||||||
|
list = list.filter(
|
||||||
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat),
|
||||||
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
||||||
@@ -134,7 +109,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
const allMaterialNames = useMemo(() => {
|
const allMaterialNames = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
materials.forEach((m) => {
|
materials.forEach((m) => {
|
||||||
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
|
const base = materialBaseName(m.name, m.brand, m.specs, m.supplier_category);
|
||||||
if (base) set.add(base);
|
if (base) set.add(base);
|
||||||
});
|
});
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
@@ -192,13 +167,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||||
}
|
}
|
||||||
const dup = materials.some(
|
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
||||||
(m) =>
|
// همان هویتِ متریال انجام میشود، نه دسته.
|
||||||
normalize(m.topic) === normalize(rec.topic) &&
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
normalize(m.category) === normalize(rec.category) &&
|
|
||||||
normalize(m.brand) === normalize(rec.brand) &&
|
|
||||||
specsKey(m.specs) === specsKey(rec.specs),
|
|
||||||
);
|
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { topicByName } from "../../lib/topics";
|
|||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
import { matchMaterial } from "../../lib/quoteUtils";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { Stepper } from "./Stepper";
|
import { Stepper } from "./Stepper";
|
||||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||||
@@ -291,22 +291,19 @@ 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) {
|
||||||
material = materials.find(
|
const matched = matchMaterial(
|
||||||
(m) =>
|
materials.filter((m) => looksLikeUuid(m.id)),
|
||||||
looksLikeUuid(m.id) &&
|
cat,
|
||||||
normalize(m.category) === normalize(cat) &&
|
brand,
|
||||||
normalize(m.brand) === normalize(brand) &&
|
specs,
|
||||||
specsKey(m.specs) === specsKey(specs),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛
|
|
||||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
|
||||||
if (!material && cat) {
|
|
||||||
material = materials.find(
|
|
||||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
|
||||||
);
|
);
|
||||||
|
if (matched) material = matched;
|
||||||
}
|
}
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material && looksLikeUuid(material.id)) {
|
if (material && looksLikeUuid(material.id)) {
|
||||||
@@ -317,10 +314,10 @@ export function WizardPage() {
|
|||||||
id: nextId("m", materials),
|
id: nextId("m", materials),
|
||||||
name: wantName,
|
name: wantName,
|
||||||
topic: selectedTopic,
|
topic: selectedTopic,
|
||||||
category: cat,
|
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit || "عدد",
|
base_unit: unit || "عدد",
|
||||||
|
supplier_category: "",
|
||||||
});
|
});
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+2
-22
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
|
|||||||
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
|
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
|
||||||
-- category_id is NULLABLE and DERIVED automatically from the first linked
|
-- Materials are global, category-free entities. name is GLOBALLY
|
||||||
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
|
|
||||||
-- unique (uq_material_name). topic is a legacy display column no longer
|
-- unique (uq_material_name). topic is a legacy display column no longer
|
||||||
-- written by the app.
|
-- written by the app.
|
||||||
CREATE TABLE materials (
|
CREATE TABLE materials (
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
|
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
topic TEXT, -- LEGACY display column
|
topic TEXT, -- LEGACY display column
|
||||||
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
@@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
|
|||||||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
|
|
||||||
-- Idempotent: only touches rows where category_id IS NULL.
|
|
||||||
CREATE OR REPLACE FUNCTION fn_derive_material_category()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
UPDATE materials
|
|
||||||
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = NEW.material_id
|
|
||||||
AND category_id IS NULL;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
|
||||||
AFTER INSERT ON supplier_materials
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
|
|
||||||
|
|
||||||
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
||||||
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
||||||
-- inserts decisions directly as 'chosen').
|
-- inserts decisions directly as 'chosen').
|
||||||
|
|||||||
Reference in New Issue
Block a user