Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9ef408ff | ||
|
|
43ff048899 | ||
|
|
c395fba8dd | ||
|
|
66216c2b2f | ||
|
|
435e6cbb21 | ||
|
|
7b3ae3cb60 | ||
|
|
ae977e5e66 | ||
|
|
b65938ef06 | ||
|
|
5084623be5 |
@@ -119,6 +119,7 @@ def _fa_validation_msg(msg: str) -> str:
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
||||||
|
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
||||||
first = exc.errors()[0]
|
first = exc.errors()[0]
|
||||||
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
||||||
field = ".".join(loc) if loc else None
|
field = ".".join(loc) if loc else None
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async def update_line_item(
|
|||||||
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
||||||
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
||||||
|
|
||||||
|
old_material_id = li.material_id
|
||||||
if payload.material_id is not None:
|
if payload.material_id is not None:
|
||||||
li.material_id = payload.material_id
|
li.material_id = payload.material_id
|
||||||
# v2.5: keep the supplier<->material catalog edge in sync.
|
# v2.5: keep the supplier<->material catalog edge in sync.
|
||||||
@@ -121,6 +122,11 @@ async def update_line_item(
|
|||||||
if payload.notes is not None:
|
if payload.notes is not None:
|
||||||
li.notes = payload.notes
|
li.notes = payload.notes
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if payload.material_id is not None and payload.material_id != old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +139,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
|
|||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
||||||
if decision is not None and decision.status == "chosen":
|
if decision is not None and decision.status == "chosen":
|
||||||
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
||||||
|
old_material_id = li.material_id
|
||||||
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": {"id": str(line_item_id), "deleted": True}}
|
return {"data": {"id": str(line_item_id), "deleted": True}}
|
||||||
|
|
||||||
|
|||||||
@@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None:
|
|||||||
return {"id": str(project.id), "name": project.name}
|
return {"id": str(project.id), "name": project.name}
|
||||||
|
|
||||||
|
|
||||||
def decision_brief(d: ItemDecision) -> dict:
|
def decision_brief(d: ItemDecision, project: Project | None = None) -> dict:
|
||||||
"""Shape used inside expanded line items (P0-14)."""
|
"""Shape used inside expanded line items (P0-14)."""
|
||||||
return {
|
return {
|
||||||
"id": str(d.id),
|
"id": str(d.id),
|
||||||
"status": d.status,
|
"status": d.status,
|
||||||
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
||||||
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
||||||
|
"project": project_brief(project) if project is not None else None,
|
||||||
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
}
|
}
|
||||||
@@ -313,6 +314,7 @@ class _LineItemData:
|
|||||||
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
||||||
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
||||||
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
||||||
|
self.projects_by_id: dict[uuid.UUID, Project] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
||||||
@@ -375,6 +377,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
if "decision" in expand or "receipt" in expand:
|
if "decision" in expand or "receipt" in expand:
|
||||||
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
data.decisions = {d.line_item_id: d for d in decisions}
|
data.decisions = {d.line_item_id: d for d in decisions}
|
||||||
|
dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None}
|
||||||
|
missing_pids = dec_proj_ids - set(proj_map.keys())
|
||||||
|
if missing_pids:
|
||||||
|
extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all()
|
||||||
|
for p in extra_projs:
|
||||||
|
proj_map[p.id] = p
|
||||||
|
data.projects_by_id = proj_map
|
||||||
dec_ids = [d.id for d in decisions]
|
dec_ids = [d.id for d in decisions]
|
||||||
if dec_ids:
|
if dec_ids:
|
||||||
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
||||||
@@ -469,8 +478,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
||||||
)
|
)
|
||||||
decision = data.decisions.get(li.id)
|
decision = data.decisions.get(li.id)
|
||||||
|
dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None
|
||||||
|
d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None
|
||||||
if "decision" in expand:
|
if "decision" in expand:
|
||||||
d["decision"] = decision_brief(decision) if decision is not None else None
|
d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None
|
||||||
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
||||||
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
||||||
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:
|
||||||
|
|||||||
@@ -279,13 +279,89 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a
|
|||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
||||||
|
"""Returns True if any warehouse receipt has been posted for the quote's items."""
|
||||||
|
count = await db.scalar(
|
||||||
|
select(func.count(ItemReceipt.id))
|
||||||
|
.join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id)
|
||||||
|
.join(LineItem, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0)
|
||||||
|
)
|
||||||
|
return (count or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
|
||||||
|
"""Check candidate material_ids and purge any that have zero references.
|
||||||
|
|
||||||
|
A material is considered an orphan if:
|
||||||
|
1. It is not referenced by ANY line_items across all quotes.
|
||||||
|
2. It is not referenced by ANY inventory stock items in warehouses.
|
||||||
|
|
||||||
|
If count == 0:
|
||||||
|
- Auxiliary records in supplier_materials, brand_materials, material_units,
|
||||||
|
and material_attribute_values are cleaned up.
|
||||||
|
- The material row is deleted from the central materials table.
|
||||||
|
|
||||||
|
Returns the list of purged material UUIDs.
|
||||||
|
"""
|
||||||
|
purged: list[UUID] = []
|
||||||
|
unique_ids = {m for m in material_ids if m is not None}
|
||||||
|
if not unique_ids:
|
||||||
|
return purged
|
||||||
|
|
||||||
|
from app.models.catalog import (
|
||||||
|
BrandMaterial,
|
||||||
|
Material,
|
||||||
|
MaterialAttributeValue,
|
||||||
|
MaterialUnit,
|
||||||
|
)
|
||||||
|
from app.models.inventory import InventoryItem
|
||||||
|
from app.models.procurement import LineItem, SupplierMaterial
|
||||||
|
|
||||||
|
for mat_id in unique_ids:
|
||||||
|
# Check active usage in line_items across the whole system
|
||||||
|
line_item_count = await db.scalar(
|
||||||
|
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (line_item_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check active usage in warehouse inventory
|
||||||
|
inv_count = await db.scalar(
|
||||||
|
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (inv_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
|
||||||
|
await db.execute(
|
||||||
|
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(Material).where(Material.id == mat_id)
|
||||||
|
)
|
||||||
|
purged.append(mat_id)
|
||||||
|
logger.info("Purged orphan material %s from central catalog", mat_id)
|
||||||
|
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||||
|
|
||||||
Only allowed while the quote is still offered — acceptance data
|
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||||
(decisions/receipts) would otherwise be destroyed.
|
Automatically purges any materials detached during this replacement if they become orphans.
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
@@ -294,6 +370,16 @@ async def replace_quote_children(
|
|||||||
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
||||||
field="status",
|
field="status",
|
||||||
)
|
)
|
||||||
|
if await quote_has_receipts(db, quote.id):
|
||||||
|
raise ConflictError(
|
||||||
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
|
field="status",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
|
||||||
|
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
|
||||||
|
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
|
||||||
|
|
||||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
@@ -302,16 +388,33 @@ async def replace_quote_children(
|
|||||||
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 2. Identify detached materials: previous materials no longer present in the updated line items
|
||||||
|
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
|
||||||
|
detached_material_ids = old_material_ids - new_material_ids
|
||||||
|
|
||||||
|
# 3. Clean up any detached materials that are now orphans (0 usage)
|
||||||
|
if detached_material_ids:
|
||||||
|
await cleanup_orphan_materials(db, detached_material_ids)
|
||||||
|
|
||||||
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||||
current = quote.status
|
current = quote.status
|
||||||
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
||||||
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
||||||
|
|
||||||
|
if await quote_has_receipts(db, quote.id):
|
||||||
|
raise ConflictError(
|
||||||
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
|
field="status",
|
||||||
|
)
|
||||||
|
|
||||||
if new_status == "offered":
|
if new_status == "offered":
|
||||||
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
||||||
quote.accepted_date = None
|
quote.accepted_date = None
|
||||||
@@ -325,17 +428,18 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(ItemDecision)
|
update(ItemDecision)
|
||||||
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
|
.where(ItemDecision.line_item_id.in_(li_ids))
|
||||||
.values(
|
.values(
|
||||||
status="pending",
|
status="pending",
|
||||||
decided_at=None,
|
decided_at=None,
|
||||||
chosen_item_termsheet_id=None,
|
chosen_item_termsheet_id=None,
|
||||||
adjusted_unit_price=None,
|
adjusted_unit_price=None,
|
||||||
adjusted_notes=None,
|
adjusted_notes=None,
|
||||||
|
project_id=None,
|
||||||
)
|
)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
# item_receipts are deliberately NOT deleted (contract P0-17).
|
# LineItemProjects (candidate projects) are preserved untouched!
|
||||||
elif new_status == "accepted": # arrived -> accepted
|
elif new_status == "accepted": # arrived -> accepted
|
||||||
quote.arrived_date = None
|
quote.arrived_date = None
|
||||||
|
|
||||||
@@ -502,21 +606,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
decision.adjusted_notes = decision_in.adjusted_notes
|
decision.adjusted_notes = decision_in.adjusted_notes
|
||||||
decision.decided_at = utcnow()
|
decision.decided_at = utcnow()
|
||||||
|
|
||||||
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
|
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
|
||||||
for entry in payload.item_projects:
|
|
||||||
if entry.line_item_id not in li_ids:
|
|
||||||
raise NotFoundError(
|
|
||||||
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(LineItemProjects)
|
|
||||||
.where(LineItemProjects.line_item_id == entry.line_item_id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
for pid in entry.project_ids:
|
|
||||||
if await db.get(Project, pid) is None:
|
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
|
|
||||||
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
|
|
||||||
|
|
||||||
# termsheets referenced by at least one chosen item -> accepted
|
# termsheets referenced by at least one chosen item -> accepted
|
||||||
if chosen_ts_ids:
|
if chosen_ts_ids:
|
||||||
@@ -692,7 +782,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
|
|||||||
|
|
||||||
|
|
||||||
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||||
async with db.begin():
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
if quote.status in ("accepted", "arrived"):
|
if quote.status in ("accepted", "arrived"):
|
||||||
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
||||||
@@ -712,18 +803,49 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||||
|
|
||||||
|
# collect candidate material_ids for orphan cleanup before deleting quote
|
||||||
|
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
|
||||||
|
|
||||||
|
# collect attachment keys before deleting quote row
|
||||||
|
from app.models.files import QuoteAttachment
|
||||||
|
from app.services import storage
|
||||||
|
|
||||||
|
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||||
|
att_keys = [att.object_key for att in attachments]
|
||||||
|
quote_prefix = f"{quote.id}/"
|
||||||
|
|
||||||
await db.delete(quote)
|
await db.delete(quote)
|
||||||
return {
|
await db.flush()
|
||||||
"id": str(quote.id),
|
|
||||||
"deleted": True,
|
# Purge any materials that no longer have any references in the system
|
||||||
"cascaded": {
|
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
|
||||||
"line_items": len(line_items),
|
|
||||||
"termsheets": len(termsheets),
|
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
|
||||||
"events": len(events),
|
for key in att_keys:
|
||||||
"decisions": len(decisions),
|
try:
|
||||||
"receipts": len(receipts),
|
await storage.remove_object(key)
|
||||||
},
|
except Exception:
|
||||||
}
|
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
|
||||||
|
try:
|
||||||
|
await storage.remove_prefix(quote_prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(quote_id),
|
||||||
|
"deleted": True,
|
||||||
|
"cascaded": {
|
||||||
|
"line_items": len(line_items),
|
||||||
|
"termsheets": len(termsheets),
|
||||||
|
"events": len(events),
|
||||||
|
"decisions": len(decisions),
|
||||||
|
"receipts": len(receipts),
|
||||||
|
"attachments": len(attachments),
|
||||||
|
"purged_orphan_materials": len(purged_material_ids),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
|
|||||||
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_prefix(prefix: str) -> None:
|
||||||
|
"""Delete all objects matching a prefix in the bucket (best-effort)."""
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
def _sync_remove_prefix():
|
||||||
|
try:
|
||||||
|
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
|
||||||
|
for obj in objects:
|
||||||
|
try:
|
||||||
|
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_sync_remove_prefix)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
daysInMonth,
|
daysInMonth,
|
||||||
gregorianToJalali,
|
gregorianToJalali,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
jalaliStrToIso,
|
jalaliStrToIso,
|
||||||
jalaliToGregorian,
|
jalaliToGregorian,
|
||||||
MONTHS,
|
MONTHS,
|
||||||
|
toAsciiDigits,
|
||||||
toFa,
|
toFa,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
} from "../lib/jalali";
|
} from "../lib/jalali";
|
||||||
@@ -14,11 +15,12 @@ import { cn } from "../lib/utils";
|
|||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
|
|
||||||
export interface JalaliDatePickerProps {
|
export interface JalaliDatePickerProps {
|
||||||
value: string | null; // ISO date (با time اختیاری)
|
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||||
onChange: (iso: string | null) => void;
|
onChange: (val: string | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayJalali(): [number, number, number] {
|
function todayJalali(): [number, number, number] {
|
||||||
@@ -28,6 +30,13 @@ function todayJalali(): [number, number, number] {
|
|||||||
|
|
||||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
|
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (m) {
|
||||||
|
const gy = parseInt(m[1], 10);
|
||||||
|
const gm = parseInt(m[2], 10);
|
||||||
|
const gd = parseInt(m[3], 10);
|
||||||
|
return gregorianToJalali(gy, gm, gd);
|
||||||
|
}
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
@@ -39,6 +48,12 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
|
|||||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
|
||||||
|
function buildDateOnly(jy: number, jm: number, jd: number): string {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
||||||
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
||||||
function nowTime(): { h: number; m: number } {
|
function nowTime(): { h: number; m: number } {
|
||||||
@@ -60,9 +75,14 @@ export function JalaliDatePicker({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
withTime = false,
|
withTime = false,
|
||||||
|
disabled = false,
|
||||||
}: JalaliDatePickerProps) {
|
}: JalaliDatePickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||||
|
const initialTime = timeOf(value);
|
||||||
|
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||||
|
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
|
||||||
|
|
||||||
const [text, setText] = useState<string>(() => {
|
const [text, setText] = useState<string>(() => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const j = isoToJalaliStr(value);
|
const j = isoToJalaliStr(value);
|
||||||
@@ -74,17 +94,49 @@ export function JalaliDatePicker({
|
|||||||
return `${j} ${faNum(hh + ":" + mm)}`;
|
return `${j} ${faNum(hh + ":" + mm)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setText("");
|
||||||
|
const now = nowTime();
|
||||||
|
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const j = isoToJalaliStr(value);
|
||||||
|
const t = timeOf(value);
|
||||||
|
if (!withTime) {
|
||||||
|
setText(j);
|
||||||
|
} else {
|
||||||
|
const hh = String(t.h).padStart(2, "0");
|
||||||
|
const mm = String(t.m).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
|
||||||
|
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
|
||||||
|
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
if (curH !== t.h) {
|
||||||
|
setHourText(faNum(hh));
|
||||||
|
}
|
||||||
|
const curM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
if (curM !== t.m) {
|
||||||
|
setMinuteText(faNum(mm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jParts = isoToJalali(value);
|
||||||
|
if (jParts) setCurJ(jParts);
|
||||||
|
}, [value, withTime]);
|
||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||||
function selectedLocalDate(): string | null {
|
function selectedLocalDate(): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
const selectedIso = selectedLocalDate();
|
const selectedIso = selectedLocalDate();
|
||||||
const time = timeOf(value);
|
|
||||||
|
|
||||||
const grid = useMemo(() => {
|
const grid = useMemo(() => {
|
||||||
const [jy, jm] = curJ;
|
const [jy, jm] = curJ;
|
||||||
@@ -124,35 +176,108 @@ export function JalaliDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||||
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
onChange(isoWithTime);
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
|
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
|
||||||
|
onChange(val);
|
||||||
|
setText(
|
||||||
|
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}`
|
||||||
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTimePart = (part: "h" | "m", raw: string) => {
|
const handleHourChange = (raw: string) => {
|
||||||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
if (Number.isNaN(n)) return;
|
if (digits.length === 3) {
|
||||||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
digits = digits.slice(-1);
|
||||||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
setHourText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(digits, 10);
|
||||||
|
const clamped = Math.min(23, Math.max(0, n));
|
||||||
|
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
|
||||||
|
setHourText(faNum(textToShow));
|
||||||
|
|
||||||
const [jy, jm, jd] = curJ;
|
const [jy, jm, jd] = curJ;
|
||||||
onChange(buildIso(jy, jm, jd, h, m));
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
// بهروزرسانی نمایش
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHourBlur = () => {
|
||||||
|
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
|
||||||
|
const n = digits ? parseInt(digits, 10) : 0;
|
||||||
|
const clamped = Math.min(23, Math.max(0, n));
|
||||||
|
setHourText(faNum(String(clamped).padStart(2, "0")));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
|
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteChange = (raw: string) => {
|
||||||
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
|
if (digits.length === 3) {
|
||||||
|
digits = digits.slice(-1);
|
||||||
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
setMinuteText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(digits, 10);
|
||||||
|
const clamped = Math.min(59, Math.max(0, n));
|
||||||
|
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
|
||||||
|
setMinuteText(faNum(textToShow));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteBlur = () => {
|
||||||
|
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
|
||||||
|
const n = digits ? parseInt(digits, 10) : 0;
|
||||||
|
const clamped = Math.min(59, Math.max(0, n));
|
||||||
|
setMinuteText(faNum(String(clamped).padStart(2, "0")));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickToday = () => {
|
const pickToday = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [jy, jm, jd] = todayJalali();
|
const [jy, jm, jd] = todayJalali();
|
||||||
// بدون withTime فقط تاریخ امروز ثبت میشود — ساعت همیشه نیمهشب
|
const nh = now.getHours();
|
||||||
const isoWithTime = withTime
|
const nm = now.getMinutes();
|
||||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
setHourText(faNum(String(nh).padStart(2, "0")));
|
||||||
: buildIso(jy, jm, jd, 0, 0);
|
setMinuteText(faNum(String(nm).padStart(2, "0")));
|
||||||
onChange(isoWithTime);
|
const val = withTime
|
||||||
|
? buildIso(jy, jm, jd, nh, nm)
|
||||||
|
: buildDateOnly(jy, jm, jd);
|
||||||
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
withTime
|
withTime
|
||||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}`
|
||||||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -161,32 +286,98 @@ export function JalaliDatePicker({
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
onChange(null);
|
onChange(null);
|
||||||
setText("");
|
setText("");
|
||||||
|
const now = nowTime();
|
||||||
|
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onInputChange = (v: string) => {
|
const onInputChange = (v: string) => {
|
||||||
setText(v);
|
setText(v);
|
||||||
|
if (!v.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const iso = jalaliStrToIso(v);
|
const iso = jalaliStrToIso(v);
|
||||||
if (iso) {
|
if (iso) {
|
||||||
onChange(iso);
|
if (!withTime) {
|
||||||
|
const nums = String(v)
|
||||||
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||||
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
|
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||||
|
} else {
|
||||||
|
onChange(iso.slice(0, 10));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(iso);
|
||||||
|
}
|
||||||
const j = isoToJalali(iso);
|
const j = isoToJalali(iso);
|
||||||
if (j) setCurJ([j[0], j[1], j[2]]);
|
if (j) setCurJ([j[0], j[1], j[2]]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const iso = jalaliStrToIso(text);
|
||||||
|
if (iso) {
|
||||||
|
if (!withTime) {
|
||||||
|
const nums = String(text)
|
||||||
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||||
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
|
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
onChange(dOnly);
|
||||||
|
setText(isoToJalaliStr(dOnly));
|
||||||
|
} else {
|
||||||
|
onChange(iso.slice(0, 10));
|
||||||
|
setText(isoToJalaliStr(iso));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(iso);
|
||||||
|
const j = isoToJalaliStr(iso);
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (value) {
|
||||||
|
const j = isoToJalaliStr(value);
|
||||||
|
if (!withTime) {
|
||||||
|
setText(j);
|
||||||
|
} else {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
disabled={disabled}
|
||||||
|
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => !disabled && setOpen(true)}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
/>
|
/>
|
||||||
{open && (
|
{open && !disabled && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||||
@@ -195,6 +386,7 @@ export function JalaliDatePicker({
|
|||||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* 1. Header: Month / Year Navigation */}
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||||
«
|
«
|
||||||
@@ -212,6 +404,8 @@ export function JalaliDatePicker({
|
|||||||
»
|
»
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Day of Week Header & Days Grid */}
|
||||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||||
{WEEKDAYS.map((w) => (
|
{WEEKDAYS.map((w) => (
|
||||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||||
@@ -238,33 +432,40 @@ export function JalaliDatePicker({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||||
{withTime && (
|
{withTime && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={faNum(String(time.h).padStart(2, "0"))}
|
value={minuteText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={2}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setTimePart("h", e.target.value)}
|
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||||
|
onBlur={handleMinuteBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span className="text-muted">:</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-muted">:</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={faNum(String(time.m).padStart(2, "0"))}
|
value={hourText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={2}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setTimePart("m", e.target.value)}
|
onChange={(e) => handleHourChange(e.target.value)}
|
||||||
|
onBlur={handleHourBlur}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* 4. Action Buttons Footer */}
|
||||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||||
امروز
|
امروز
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export interface MilestoneRowProps {
|
|||||||
milestone: Milestone;
|
milestone: Milestone;
|
||||||
onChange: (m: Milestone) => void;
|
onChange: (m: Milestone) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="milestone-row">
|
<div className="milestone-row">
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
@@ -17,6 +18,7 @@ export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProp
|
|||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
placeholder="تاریخ"
|
placeholder="تاریخ"
|
||||||
className="w-36 shrink-0"
|
className="w-36 shrink-0"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<div className="ms-amount-wrap">
|
<div className="ms-amount-wrap">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -24,11 +26,14 @@ export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProp
|
|||||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||||
placeholder="مبلغ (تومان)"
|
placeholder="مبلغ (تومان)"
|
||||||
showWords
|
showWords
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
{!disabled && (
|
||||||
×
|
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface MultiSelectComboboxProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
||||||
@@ -27,6 +28,7 @@ export function MultiSelectCombobox({
|
|||||||
className,
|
className,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
|
disabled = false,
|
||||||
}: MultiSelectComboboxProps) {
|
}: MultiSelectComboboxProps) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -105,46 +107,56 @@ export function MultiSelectCombobox({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<div
|
<div
|
||||||
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
|
className={cn(
|
||||||
onClick={() => inputRef.current?.focus()}
|
"flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40",
|
||||||
|
disabled && "cursor-not-allowed opacity-60 bg-canvas/40",
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
{values.map((v) => (
|
{values.map((v) => (
|
||||||
<span key={v} className="spec-chip">
|
<span key={v} className="spec-chip">
|
||||||
<span className="max-w-[140px] truncate">{v}</span>
|
<span className="max-w-[140px] truncate">{v}</span>
|
||||||
<button
|
{!disabled && (
|
||||||
type="button"
|
<button
|
||||||
className="spec-chip-x"
|
type="button"
|
||||||
title="حذف"
|
className="spec-chip-x"
|
||||||
onClick={(e) => {
|
title="حذف"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setPendingRemove(v);
|
e.stopPropagation();
|
||||||
}}
|
setPendingRemove(v);
|
||||||
>
|
}}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
|
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted disabled:cursor-not-allowed",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={values.length ? "" : placeholder}
|
placeholder={values.length ? "" : placeholder}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onKeyDown(e);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && !disabled && (
|
||||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||||
{listCount === 0 && (
|
{listCount === 0 && (
|
||||||
<div className="px-3 py-1.5 text-[12px] text-muted">
|
<div className="px-3 py-1.5 text-[12px] text-muted">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface NumberInputProps {
|
|||||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||||
inputMode?: "numeric" | "decimal" | undefined;
|
inputMode?: "numeric" | "decimal" | undefined;
|
||||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
||||||
@@ -21,6 +22,7 @@ export function NumberInput({
|
|||||||
showWords = false,
|
showWords = false,
|
||||||
inputMode = "numeric",
|
inputMode = "numeric",
|
||||||
maxDecimals,
|
maxDecimals,
|
||||||
|
disabled = false,
|
||||||
}: NumberInputProps) {
|
}: NumberInputProps) {
|
||||||
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
||||||
const formatValue = (n: number): string => {
|
const formatValue = (n: number): string => {
|
||||||
@@ -62,7 +64,8 @@ export function NumberInput({
|
|||||||
<div className={cn("min-w-0", className)}>
|
<div className={cn("min-w-0", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
disabled={disabled}
|
||||||
|
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function useAddMaterial() {
|
|||||||
const invalidate = useInvalidateStore();
|
const invalidate = useInvalidateStore();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (m: Material) => api.materials.create(m),
|
mutationFn: (m: Material) => api.materials.create(m),
|
||||||
onMutate: (m) => addMaterial(m),
|
onSuccess: (created) => addMaterial(created),
|
||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-51
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { normalize } from "./normalize";
|
import { normalize } from "./normalize";
|
||||||
import { buildTopics } from "./topics";
|
import { buildTopics } from "./topics";
|
||||||
|
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -33,7 +34,7 @@ import type {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
||||||
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
|
|||||||
expiry_date: "تاریخ انقضا",
|
expiry_date: "تاریخ انقضا",
|
||||||
accepted_date: "تاریخ پذیرش",
|
accepted_date: "تاریخ پذیرش",
|
||||||
arrived_date: "تاریخ رسید",
|
arrived_date: "تاریخ رسید",
|
||||||
|
due_date: "تاریخ سررسید",
|
||||||
|
schedule_steps: "مراحل پرداخت",
|
||||||
|
milestones: "مراحل پرداخت",
|
||||||
|
amount: "مبلغ",
|
||||||
|
step_no: "شماره مرحله",
|
||||||
};
|
};
|
||||||
|
|
||||||
const faField = (field: string): string => {
|
export const faField = (field: string): string => {
|
||||||
const last = field.split(".").filter(Boolean).pop() || field;
|
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
|
||||||
|
const last = parts.pop() || field;
|
||||||
return FIELD_FA[last] || FIELD_FA[field] || field;
|
return FIELD_FA[last] || FIELD_FA[field] || field;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ترجمهی خطای HTTP بر اساس کد وضعیت
|
// ترجمهی خطای HTTP بر اساس کد وضعیت — پیامهای معنادار بکاند حفظ میشوند
|
||||||
const faHttpError = (status: number, fallback: string): string => {
|
export const faHttpError = (status: number, fallback: string): string => {
|
||||||
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
||||||
if (status === 404) return "مورد درخواستی یافت نشد";
|
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
|
||||||
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||||
if (status === 400) return "دادهی ارسالشده نامعتبر است";
|
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "دادهی ارسالشده نامعتبر است";
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
field?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, code?: string, field?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.field = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
headers: authHeaders(init),
|
headers: authHeaders(init),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
|
let errCode: string | undefined;
|
||||||
|
let errField: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body?.error?.message) {
|
if (body?.error) {
|
||||||
msg = body.error.message;
|
errCode = body.error.code;
|
||||||
|
errField = body.error.field;
|
||||||
|
const rawMsg = body.error.message || "";
|
||||||
|
const fName = errField ? faField(errField) : "";
|
||||||
|
if (fName && rawMsg) {
|
||||||
|
msg = `دادهی «${fName}» نامعتبر است: ${rawMsg}`;
|
||||||
|
} else if (rawMsg) {
|
||||||
|
msg = rawMsg;
|
||||||
|
}
|
||||||
} else if (Array.isArray(body?.detail)) {
|
} else if (Array.isArray(body?.detail)) {
|
||||||
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
||||||
const first = body.detail[0];
|
const first = body.detail[0];
|
||||||
const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : "";
|
const loc = first?.loc || [];
|
||||||
|
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
|
||||||
|
errField = filteredLoc.join(".");
|
||||||
|
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
|
||||||
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
||||||
msg = field ? `دادهی «${faField(field)}» نامعتبر است: ${fa}` : fa;
|
const fName = fieldName ? faField(fieldName) : "";
|
||||||
|
msg = fName ? `دادهی «${fName}» نامعتبر است: ${fa}` : fa;
|
||||||
} else if (body?.detail) {
|
} else if (body?.detail) {
|
||||||
msg = String(body.detail);
|
msg = String(body.detail);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* بدنۀ خطا JSON نیست */
|
/* بدنۀ خطا JSON نیست */
|
||||||
}
|
}
|
||||||
throw new Error(faHttpError(res.status, msg));
|
const finalMsg = faHttpError(res.status, msg);
|
||||||
|
throw new ApiError(finalMsg, res.status, errCode, errField);
|
||||||
}
|
}
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
@@ -285,6 +322,8 @@ interface RawDecision {
|
|||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
chosen_item_termsheet_id?: string | null;
|
chosen_item_termsheet_id?: string | null;
|
||||||
|
project_id?: string | null;
|
||||||
|
project?: RawProject | null;
|
||||||
adjusted_unit_price?: number | null;
|
adjusted_unit_price?: number | null;
|
||||||
decided_at?: string | null;
|
decided_at?: string | null;
|
||||||
}
|
}
|
||||||
@@ -309,6 +348,7 @@ interface RawLineItem {
|
|||||||
termsheet_ids?: string[] | null;
|
termsheet_ids?: string[] | null;
|
||||||
accepted_termsheet_id?: string | null; // id ترمشیت پذیرفتهشده (task: خط آبی)
|
accepted_termsheet_id?: string | null; // id ترمشیت پذیرفتهشده (task: خط آبی)
|
||||||
projects?: RawProject[] | null; // پروژههای تخصیصیافته به این قلم
|
projects?: RawProject[] | null; // پروژههای تخصیصیافته به این قلم
|
||||||
|
target_project?: RawProject | null;
|
||||||
}
|
}
|
||||||
interface RawTermsheet {
|
interface RawTermsheet {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -517,6 +557,7 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
||||||
|
const targetProj = raw.target_project || raw.decision?.project || null;
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
material_id: raw.material?.id || "",
|
material_id: raw.material?.id || "",
|
||||||
@@ -537,6 +578,8 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
|||||||
quantity: a.quantity,
|
quantity: a.quantity,
|
||||||
})),
|
})),
|
||||||
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
|
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
|
||||||
|
target_project: targetProj ? { id: targetProj.id, name: targetProj.name } : null,
|
||||||
|
target_project_id: raw.decision?.project_id || targetProj?.id || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
|
|||||||
|
|
||||||
function toDateOnly(v: string | null | undefined): string | null {
|
function toDateOnly(v: string | null | undefined): string | null {
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/);
|
const str = toAsciiDigits(v).trim();
|
||||||
if (m) return m[1];
|
if (!str) return null;
|
||||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
|
||||||
const d = new Date(v);
|
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سالهای ۱۳۰۰ تا ۱۵۰۰)
|
||||||
|
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
||||||
|
if (jm) {
|
||||||
|
const jy = parseInt(jm[1], 10);
|
||||||
|
const jmon = parseInt(jm[2], 10);
|
||||||
|
const jd = parseInt(jm[3], 10);
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. فرمت میلادی استاندارد YYYY-MM-DD
|
||||||
|
const gm = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (gm && parseInt(gm[1], 10) >= 1900) return str;
|
||||||
|
|
||||||
|
// ۳. رشتهی datetime کامل یا مقادیر دیگر
|
||||||
|
const d = new Date(str);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateTime(v: string | null | undefined): string | null {
|
function toDateTime(v: string | null | undefined): string | null {
|
||||||
const d = toDateOnly(v);
|
if (!v) return null;
|
||||||
if (!d) return null;
|
const str = toAsciiDigits(v).trim();
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
|
if (!str) return null;
|
||||||
|
|
||||||
|
// ۱. تاریخ جلالی با زمان اختیاری
|
||||||
|
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||||
|
if (jm) {
|
||||||
|
const jy = parseInt(jm[1], 10);
|
||||||
|
const jmon = parseInt(jm[2], 10);
|
||||||
|
const jd = parseInt(jm[3], 10);
|
||||||
|
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
|
||||||
|
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
|
||||||
|
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||||
|
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. اگر میلادی استاندارد ISO است
|
||||||
|
const d = new Date(str);
|
||||||
|
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnly = toDateOnly(str);
|
||||||
|
return dateOnly ? dateOnly + "T00:00:00Z" : null;
|
||||||
}
|
}
|
||||||
function todayIso(): string {
|
function todayIso(): string {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
function looksLikeUuid(v: string): boolean {
|
export function looksLikeUuid(v: string | null | undefined): boolean {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
if (!v) return false;
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -771,6 +857,13 @@ export const api = {
|
|||||||
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
||||||
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
||||||
for (let i = 0; i < lineItems.length; i++) {
|
for (let i = 0; i < lineItems.length; i++) {
|
||||||
|
let matId = realMaterialId(lineItems[i].material_id);
|
||||||
|
if (!looksLikeUuid(matId)) {
|
||||||
|
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
|
||||||
|
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
|
||||||
|
if (found) matId = found.id;
|
||||||
|
}
|
||||||
|
lineItems[i].material_id = matId;
|
||||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
||||||
for (const p of q.line_items[i].projects || []) {
|
for (const p of q.line_items[i].projects || []) {
|
||||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||||
@@ -855,23 +948,16 @@ export const api = {
|
|||||||
|
|
||||||
if (becomingAccepted) {
|
if (becomingAccepted) {
|
||||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||||
// تخصیص پروژه به هر قلم — نامها به id واقعی ترجمه میشوند
|
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
||||||
const itemProjects: { line_item_id: string; project_ids: string[] }[] = [];
|
const targetProjByLine = new Map<string, string>();
|
||||||
const allProjectIds: string[] = [];
|
|
||||||
const seenPids = new Set<string>();
|
|
||||||
for (const li of patch.line_items || []) {
|
for (const li of patch.line_items || []) {
|
||||||
const ids: string[] = [];
|
const tp = li.target_project;
|
||||||
for (const p of li.projects || []) {
|
if (tp && tp.name) {
|
||||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||||
if (!pid) continue;
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
ids.push(pid);
|
|
||||||
if (!seenPids.has(pid)) {
|
|
||||||
seenPids.add(pid);
|
|
||||||
allProjectIds.push(pid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemProjects.push({ line_item_id: li.id, project_ids: ids });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
accepted_termsheets: acceptedTs.map((t) => ({
|
accepted_termsheets: acceptedTs.map((t) => ({
|
||||||
id: looksLikeUuid(t.id) ? t.id : undefined,
|
id: looksLikeUuid(t.id) ? t.id : undefined,
|
||||||
@@ -887,11 +973,7 @@ export const api = {
|
|||||||
item_decisions: (patch.line_items || []).map((li) => {
|
item_decisions: (patch.line_items || []).map((li) => {
|
||||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||||
const chosen = !li.rejected && idx >= 0;
|
const chosen = !li.rejected && idx >= 0;
|
||||||
// پروژهی واحدِ انتخابشده برای این قلم — مستقیم داخل decision میرود تا
|
const chosenProjId = targetProjByLine.get(li.id);
|
||||||
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
|
|
||||||
// نشان دهد؛ قبلاً فقط item_projects جداگانه میرفت و decision بدون
|
|
||||||
// پروژه میماند (قلمها در پنل هیچ مدیر پروژهای دیده نمیشدند)
|
|
||||||
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
|
|
||||||
return {
|
return {
|
||||||
line_item_id: li.id,
|
line_item_id: li.id,
|
||||||
status: chosen ? "chosen" : "rejected",
|
status: chosen ? "chosen" : "rejected",
|
||||||
@@ -900,14 +982,10 @@ export const api = {
|
|||||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
item_projects: itemProjects,
|
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||||
};
|
};
|
||||||
await patchData(`/quotes/${id}/accept`, payload);
|
await patchData(`/quotes/${id}/accept`, payload);
|
||||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
return { ...current, ...patch, projects: current.projects } as Quote;
|
||||||
if (allProjectIds.length) {
|
|
||||||
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
|
|
||||||
}
|
|
||||||
return { ...current, ...patch } as Quote;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
||||||
@@ -1045,8 +1123,10 @@ export const api = {
|
|||||||
const unitId = await unitIdByNameOrCreate(lookup, unitName);
|
const unitId = await unitIdByNameOrCreate(lookup, unitName);
|
||||||
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل میشود
|
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل میشود
|
||||||
const brandId = await brandIdByNameOrCreate(lookup, m.brand);
|
const brandId = await brandIdByNameOrCreate(lookup, m.brand);
|
||||||
// تایپ جلوی برنامه name ندارد — از متریال/برند/مشخصه ساخته میشود
|
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
||||||
const name = [m.brand, m.category, ...(m.specs || [])].filter(Boolean).join(" ") || "کالای جدید";
|
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
||||||
|
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
||||||
|
const name = (m.category || 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>();
|
||||||
@@ -1220,6 +1300,17 @@ export const api = {
|
|||||||
brandsList: async (): Promise<{ id: string; name: string }[]> => {
|
brandsList: async (): Promise<{ id: string; name: string }[]> => {
|
||||||
return getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`);
|
return getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`);
|
||||||
},
|
},
|
||||||
|
// مشخصهها — رجیستری سراسری (attribute-definitions)، مستقل از تاپیک/دسته/برند.
|
||||||
|
// ویزارد از این لیست بهعنوان منبع سراسری مشخصهها استفاده میکند تا مشخصههای
|
||||||
|
// ثبتشده در پیشنهادهای قبلی همیشه در دسترس باشند.
|
||||||
|
specsList: async (): Promise<string[]> => {
|
||||||
|
const raw = await getData<{ label?: string; attribute_key?: string }[]>(
|
||||||
|
`/attribute-definitions?page_size=${PAGE_SIZE}`,
|
||||||
|
);
|
||||||
|
return raw
|
||||||
|
.map((d) => (d.label || "").trim() || (d.attribute_key || "").replace(/^spec_/, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
// ---------- واحدها (از بکاند) ----------
|
// ---------- واحدها (از بکاند) ----------
|
||||||
unitsList: async (): Promise<RawUnit[]> => {
|
unitsList: async (): Promise<RawUnit[]> => {
|
||||||
@@ -1250,6 +1341,10 @@ async function fetchProjects(): Promise<RawProject[]> {
|
|||||||
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const projects = await fetchProjects();
|
const projects = await fetchProjects();
|
||||||
|
if (looksLikeUuid(name)) {
|
||||||
|
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
|
||||||
|
if (byId) return byId.id;
|
||||||
|
}
|
||||||
const pid = projectIdByName(projects, name);
|
const pid = projectIdByName(projects, name);
|
||||||
if (pid) return pid;
|
if (pid) return pid;
|
||||||
const raw = await postData<RawProject>("/projects", { name });
|
const raw = await postData<RawProject>("/projects", { name });
|
||||||
@@ -1381,8 +1476,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
|
|||||||
|
|
||||||
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
||||||
export function nextId(prefix: string, items: { id: string }[]): string {
|
export function nextId(prefix: string, items: { id: string }[]): string {
|
||||||
|
const regex = new RegExp(`^${prefix}(\\d+)$`);
|
||||||
const max = items
|
const max = items
|
||||||
.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10))
|
.map((x) => {
|
||||||
|
const m = String(x.id).match(regex);
|
||||||
|
return m ? parseInt(m[1], 10) : 0;
|
||||||
|
})
|
||||||
.filter((n) => !Number.isNaN(n))
|
.filter((n) => !Number.isNaN(n))
|
||||||
.reduce((a, b) => Math.max(a, b), 0);
|
.reduce((a, b) => Math.max(a, b), 0);
|
||||||
return prefix + (max + 1);
|
return prefix + (max + 1);
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ export const MONTHS = [
|
|||||||
|
|
||||||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||||
|
|
||||||
function toEnSafe(s: string): string {
|
export function toAsciiDigits(s: string | number | null | undefined): string {
|
||||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
||||||
@@ -121,16 +124,29 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
|
|||||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
|
// parse a jalali "yyyy/m/d" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string
|
||||||
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const nums = String(str)
|
const s = toAsciiDigits(str).trim();
|
||||||
.split(/[/\-.]/)
|
const m = s.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||||
.map((x) => parseInt(toEnSafe(x), 10));
|
if (!m) {
|
||||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
const nums = s
|
||||||
const [jy, jm, jd] = nums;
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(x, 10));
|
||||||
|
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||||
|
const [jy, jm, jd] = nums;
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
|
const dt = new Date(gy, gm - 1, gd);
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||||
|
}
|
||||||
|
const jy = parseInt(m[1], 10);
|
||||||
|
const jm = parseInt(m[2], 10);
|
||||||
|
const jd = parseInt(m[3], 10);
|
||||||
|
const h = m[4] != null ? parseInt(m[4], 10) : 0;
|
||||||
|
const min = m[5] != null ? parseInt(m[5], 10) : 0;
|
||||||
|
const sec = m[6] != null ? parseInt(m[6], 10) : 0;
|
||||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
const dt = new Date(gy, gm - 1, gd);
|
const dt = new Date(gy, gm - 1, gd, h, min, sec);
|
||||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,20 +40,58 @@ export function specsLabel(specs: string[]): string {
|
|||||||
return specs.join(" · ");
|
return specs.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
|
/**
|
||||||
// برند و مشخصه فقط وقتی به نام اضافه میشوند که داخلش نیامده باشند (بدون تکرار).
|
* عنوان تمیز و بدون تکرار کالا:
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
|
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
||||||
const name = (m.name || "").trim();
|
*/
|
||||||
const specs = (m.specs || []).filter(Boolean);
|
export function formatMaterialTitle(
|
||||||
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
|
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||||
if (!name) {
|
extraSpecs?: string[],
|
||||||
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
|
): string {
|
||||||
|
if (!m) return "کالا";
|
||||||
|
const rawName = (m.name || "").trim();
|
||||||
|
const rawBrand = (m.brand || "").trim();
|
||||||
|
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||||
|
.map((s) => (s || "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const normName = normalize(rawName);
|
||||||
|
const normBrand = normalize(rawBrand);
|
||||||
|
|
||||||
|
if (!rawName) {
|
||||||
|
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
||||||
|
if (rawBrand) return rawBrand;
|
||||||
|
if (rawSpecs.length) return rawSpecs.join(" · ");
|
||||||
|
return m.category && m.category !== "؟" ? m.category : "کالا";
|
||||||
}
|
}
|
||||||
const parts = [name];
|
|
||||||
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
|
let title = rawName;
|
||||||
const missing = specs.filter((s) => !name.includes(s));
|
if (normBrand && !normName.includes(normBrand)) {
|
||||||
if (missing.length) parts.push(`(${missing.join("، ")})`);
|
title = `${rawBrand} ${title}`;
|
||||||
return parts.join(" ");
|
}
|
||||||
|
|
||||||
|
const normTitle = normalize(title);
|
||||||
|
const distinctSpecs: string[] = [];
|
||||||
|
const seenNormSpecs = new Set<string>();
|
||||||
|
|
||||||
|
for (const s of rawSpecs) {
|
||||||
|
const ns = normalize(s);
|
||||||
|
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||||
|
seenNormSpecs.add(ns);
|
||||||
|
|
||||||
|
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
||||||
|
if (normTitle.includes(ns)) continue;
|
||||||
|
distinctSpecs.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distinctSpecs.length > 0) {
|
||||||
|
return `${title} · ${distinctSpecs.join(" · ")}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||||
|
return formatMaterialTitle(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||||
@@ -104,9 +142,15 @@ export function lineItemTotal(li: LineItem): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// پروژههای یک قلم — فقط پروژههای تخصیصیافته به همین قلم (بدون fallback به سطح پیشنهاد)
|
// پروژههای یک قلم:
|
||||||
export function itemProjects(_q: Quote, li: LineItem): string[] {
|
// - در وضعیت offered: تمام پروژههای کاندیدا (line_item_projects)
|
||||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
||||||
|
export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||||
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
|
return li.target_project?.name ? [li.target_project.name] : [];
|
||||||
|
}
|
||||||
|
// For 'offered', 'rejected', 'expired': return all candidate projects
|
||||||
|
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||||
@@ -163,8 +207,24 @@ export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: stri
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ستون پروژه
|
// ستون پروژه:
|
||||||
|
// در وضعیتهای پذیرفتهشده یا رسیده، فقط پروژههای هدف خرید اقلام نشان داده میشوند
|
||||||
export function projectCell(q: Quote): string {
|
export function projectCell(q: Quote): string {
|
||||||
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
|
const targets = (q.line_items || [])
|
||||||
|
.filter((li) => !li.rejected)
|
||||||
|
.map((li) =>
|
||||||
|
li.target_project?.name ||
|
||||||
|
(li.projects && li.projects.length === 1
|
||||||
|
? typeof li.projects[0] === "string"
|
||||||
|
? li.projects[0]
|
||||||
|
: li.projects[0].name
|
||||||
|
: null),
|
||||||
|
)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
const unique = [...new Set(targets)];
|
||||||
|
if (unique.length) return unique.join("، ");
|
||||||
|
}
|
||||||
if (q.projects && q.projects.length) return q.projects.join("، ");
|
if (q.projects && q.projects.length) return q.projects.join("، ");
|
||||||
return q.project || "—";
|
return q.project || "—";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ export interface LineItem {
|
|||||||
arrived_date: string | null; // تاریخ آخرین رسید
|
arrived_date: string | null; // تاریخ آخرین رسید
|
||||||
received_qty?: number | null; // مجموع رسیدهای ثبتشده (رسید جزیی) — null = هنوز دادهای نیست
|
received_qty?: number | null; // مجموع رسیدهای ثبتشده (رسید جزیی) — null = هنوز دادهای نیست
|
||||||
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
|
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
|
||||||
projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم
|
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||||
|
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||||
|
target_project_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// پیشنهاد خرید (اصلیترین entity)
|
// پیشنهاد خرید (اصلیترین entity)
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { api, nextDashedId } from "../../lib/api";
|
|||||||
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
||||||
import {
|
import {
|
||||||
activeItems,
|
activeItems,
|
||||||
|
formatMaterialTitle,
|
||||||
itemTermsheetIds,
|
itemTermsheetIds,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
|
||||||
materialSpecs,
|
|
||||||
specsLabel,
|
|
||||||
supplierById,
|
supplierById,
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { faNum, fmtNum } from "../../lib/formatters";
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
@@ -76,11 +74,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
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);
|
||||||
projSel[li.id] = names.length
|
projSel[li.id] = li.target_project?.name
|
||||||
? names[0]
|
? li.target_project.name
|
||||||
: q.projects && q.projects.length
|
: names.length
|
||||||
? q.projects[0]
|
? names[0]
|
||||||
: q.project || "";
|
: q.projects && q.projects.length
|
||||||
|
? q.projects[0]
|
||||||
|
: q.project || "";
|
||||||
});
|
});
|
||||||
setItemProjectsSel(projSel);
|
setItemProjectsSel(projSel);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
@@ -219,17 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهی هر قلم (تکی) + همگامسازی پروژههای سطح پیشنهاد
|
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
||||||
const projUnion = new Set<string>();
|
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const name = itemProjectsSel[li.id];
|
||||||
if (name === undefined) return;
|
if (name === undefined) return;
|
||||||
li.projects = name ? [{ id: "", name }] : [];
|
li.target_project = name ? { id: "", name } : null;
|
||||||
if (name) projUnion.add(name);
|
|
||||||
});
|
});
|
||||||
next.projects = [...projUnion];
|
|
||||||
next.project = next.projects[0] || "";
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
next.accepted_date = acceptDate || now;
|
next.accepted_date = acceptDate || now;
|
||||||
@@ -298,7 +295,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
{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);
|
||||||
const specText = specsLabel(materialSpecs(m));
|
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
||||||
<input
|
<input
|
||||||
@@ -314,8 +310,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 text-[13px]">
|
<span className="flex-1 text-[13px]">
|
||||||
{materialLabel(m)}
|
{formatMaterialTitle(m)}
|
||||||
{specText ? ` · ${specText}` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] text-muted">
|
<span className="text-[12px] text-muted">
|
||||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||||
@@ -351,14 +346,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
.filter((li) => !removedItems.has(li.id))
|
.filter((li) => !removedItems.has(li.id))
|
||||||
.map((li) => {
|
.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
const specText = specsLabel(materialSpecs(m));
|
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||||
<div className="mb-1.5 text-[12.5px]">
|
<div className="mb-1.5 text-[12.5px]">
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{materialLabel(m)}
|
{formatMaterialTitle(m)}
|
||||||
</span>
|
</span>
|
||||||
{specText ? <span className="text-muted"> · {specText}</span> : ""}
|
|
||||||
<span className="ms-2 text-[11px] text-muted">
|
<span className="ms-2 text-[11px] text-muted">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { toast } from "sonner";
|
|||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import type { Quote } from "../../lib/types";
|
import type { Quote } from "../../lib/types";
|
||||||
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
||||||
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
import { activeItems, formatMaterialTitle, materialById, supplierById } from "../../lib/quoteUtils";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -86,7 +86,6 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{items.map((li) => {
|
{items.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
const specText = materialSpecs(m).join(" · ");
|
|
||||||
return (
|
return (
|
||||||
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
||||||
<input
|
<input
|
||||||
@@ -102,8 +101,7 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||||
{m.category} {m.brand}
|
{formatMaterialTitle(m)}
|
||||||
{specText ? ` · ${specText}` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="arrive-meta">
|
<span className="arrive-meta">
|
||||||
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2, AlertCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
import {
|
import {
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
useQuotes,
|
useQuotes,
|
||||||
useSuppliers,
|
useSuppliers,
|
||||||
useUpdateQuote,
|
useUpdateQuote,
|
||||||
|
useDeleteQuote,
|
||||||
useAddSupplier,
|
useAddSupplier,
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
interface EditItemRow {
|
interface EditItemRow {
|
||||||
key: string;
|
key: string;
|
||||||
origLi?: LineItem;
|
origLi?: LineItem;
|
||||||
|
projects?: { id: string; name: string }[];
|
||||||
cat: string;
|
cat: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
specs: string[];
|
specs: string[];
|
||||||
@@ -59,18 +62,27 @@ export interface EditModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مودال ویرایش کامل پیشنهاد
|
// مودال ویرایش کامل پیشنهاد — بهینهشده برای صفحات موبایل
|
||||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const projects = useProjects();
|
const projects = useProjects();
|
||||||
const updateQuote = useUpdateQuote();
|
const updateQuote = useUpdateQuote();
|
||||||
|
const deleteQuote = useDeleteQuote();
|
||||||
const addSupplier = useAddSupplier();
|
const addSupplier = useAddSupplier();
|
||||||
const addMaterial = useAddMaterial();
|
const addMaterial = useAddMaterial();
|
||||||
|
|
||||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||||
|
|
||||||
|
const hasReceipts = useMemo(() => {
|
||||||
|
if (!q) return false;
|
||||||
|
if (q.status === "arrived") return true;
|
||||||
|
return (q.line_items || []).some(
|
||||||
|
(li) => (li.received_qty != null && li.received_qty > 0) || !!li.arrived_date,
|
||||||
|
);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [supplierName, setSupplierName] = useState("");
|
const [supplierName, setSupplierName] = useState("");
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
@@ -83,16 +95,46 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
|
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
|
||||||
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
|
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState("offered");
|
const [status, setStatus] = useState("offered");
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!q) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteQuote.mutateAsync(q.id);
|
||||||
|
toast.success("پیشنهاد خرید با موفقیت حذف شد");
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "حذف پیشنهاد ناموفق بود");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// گزینههای مشخصه
|
||||||
|
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void api
|
||||||
|
.specsList()
|
||||||
|
.then((list) => alive && setGlobalSpecs(list))
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// گزینههای مشخصه — همهی مشخصههای ثبتشده (کش)
|
|
||||||
const specOptions = useMemo(() => {
|
const specOptions = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
|
globalSpecs.forEach((s) => s && set.add(s));
|
||||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s)));
|
materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s)));
|
||||||
itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s)));
|
itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s)));
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}, [materials, itemRows]);
|
}, [materials, itemRows, globalSpecs]);
|
||||||
|
|
||||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای خود پیشنهاد
|
// گزینههای پروژه
|
||||||
const projectOptions = useMemo(() => {
|
const projectOptions = useMemo(() => {
|
||||||
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));
|
||||||
@@ -107,7 +149,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
if (!q) return;
|
if (!q) return;
|
||||||
const sup = supplierById(suppliers, q.supplier_id);
|
const sup = supplierById(suppliers, q.supplier_id);
|
||||||
setSupplierName(sup.name);
|
setSupplierName(sup.name);
|
||||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
||||||
|
const candidateSet = new Set<string>();
|
||||||
|
(q.projects || []).forEach((p) => p && candidateSet.add(p));
|
||||||
|
if (q.project) candidateSet.add(q.project);
|
||||||
|
q.line_items.forEach((li) => {
|
||||||
|
(li.projects || []).forEach((p) => {
|
||||||
|
const n = typeof p === "string" ? p : p.name;
|
||||||
|
if (n) candidateSet.add(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setSelectedProjects([...candidateSet]);
|
||||||
setNotes(q.notes || "");
|
setNotes(q.notes || "");
|
||||||
setItemRows(
|
setItemRows(
|
||||||
q.line_items.map((li) => {
|
q.line_items.map((li) => {
|
||||||
@@ -115,6 +167,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
|
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||||||
cat: m.category || "",
|
cat: m.category || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
@@ -144,7 +197,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q, suppliers, materials]);
|
}, [q, suppliers, materials]);
|
||||||
|
|
||||||
// فاکتورها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
// بارگذاری فاکتورها
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -204,9 +257,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
|
if (hasReceipts && status !== q.status) {
|
||||||
|
toast.error("این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
// تأمینکننده — موجود یا جدید
|
// تأمینکننده
|
||||||
if (supplierName.trim()) {
|
if (supplierName.trim()) {
|
||||||
const norm = normalize(supplierName);
|
const norm = normalize(supplierName);
|
||||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||||
@@ -215,7 +273,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
} else {
|
} else {
|
||||||
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
||||||
const created = await addSupplier.mutateAsync(newSup);
|
const created = await addSupplier.mutateAsync(newSup);
|
||||||
next.supplier_id = created.id; // UUID واقعی از بکاند
|
next.supplier_id = created.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +282,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
next.notes = notes.trim();
|
next.notes = notes.trim();
|
||||||
next.invoices = invoices;
|
next.invoices = invoices;
|
||||||
|
|
||||||
// termsheetها — id موجود حفظ میشود، جدیدها ساخته میشوند
|
// termsheetها
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
tsCards.forEach((card, i) => {
|
tsCards.forEach((card, i) => {
|
||||||
@@ -252,7 +310,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const qty = row.qty;
|
const qty = row.qty;
|
||||||
const price = row.price;
|
const price = row.price;
|
||||||
const total = row.total;
|
const total = row.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
let material = materialsCopy.find(
|
let material = materialsCopy.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
@@ -266,7 +324,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
} else {
|
} else {
|
||||||
const nm = {
|
const nm = {
|
||||||
id: nextId("m", materialsCopy),
|
id: nextId("m", materialsCopy),
|
||||||
name: [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید",
|
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
||||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||||
category: cat,
|
category: cat,
|
||||||
brand,
|
brand,
|
||||||
@@ -274,7 +332,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
base_unit: unit,
|
base_unit: unit,
|
||||||
};
|
};
|
||||||
materialsCopy.push(nm);
|
materialsCopy.push(nm);
|
||||||
// منتظر ساخت متریال میمانیم تا id واقعی (UUID) به قلم وصل شود
|
|
||||||
const created = await addMaterial.mutateAsync(nm);
|
const created = await addMaterial.mutateAsync(nm);
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
}
|
}
|
||||||
@@ -304,8 +361,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
acceptedTsId = null;
|
acceptedTsId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candProjs =
|
||||||
|
row.projects && row.projects.length
|
||||||
|
? row.projects
|
||||||
|
: row.origLi?.projects && row.origLi.projects.length
|
||||||
|
? row.origLi.projects
|
||||||
|
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
||||||
|
|
||||||
newItems.push({
|
newItems.push({
|
||||||
id: "li-" + (newItems.length + 1),
|
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
|
||||||
material_id: materialId,
|
material_id: materialId,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit,
|
unit,
|
||||||
@@ -315,12 +379,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
accepted_ts_id: acceptedTsId,
|
accepted_ts_id: acceptedTsId,
|
||||||
rejected,
|
rejected,
|
||||||
arrived_date: row.origLi?.arrived_date ?? null,
|
arrived_date: row.origLi?.arrived_date ?? null,
|
||||||
projects: row.origLi?.projects ?? [],
|
projects: candProjs,
|
||||||
|
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
||||||
|
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
next.line_items = newItems.length ? newItems : next.line_items;
|
next.line_items = newItems.length ? newItems : next.line_items;
|
||||||
|
|
||||||
// ترمشیت بدون قلم بیمعنی است — حذف
|
|
||||||
const referencedTs = new Set<string>();
|
const referencedTs = new Set<string>();
|
||||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
||||||
@@ -332,12 +397,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
// تاریخها و وضعیت
|
// تاریخها و وضعیت
|
||||||
next.quote_date = quoteDate || q.quote_date;
|
next.quote_date = quoteDate || q.quote_date;
|
||||||
next.expiry_date = expiryDate || "";
|
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
||||||
next.accepted_date = acceptedDate;
|
next.accepted_date = acceptedDate;
|
||||||
next.arrived_date = arrivedDate;
|
next.arrived_date = arrivedDate;
|
||||||
next.status = status as Quote["status"];
|
next.status = status as Quote["status"];
|
||||||
|
|
||||||
// پاکسازی خودکار در بازگشت وضعیت
|
|
||||||
if (next.status === "offered") {
|
if (next.status === "offered") {
|
||||||
next.accepted_date = null;
|
next.accepted_date = null;
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
@@ -348,6 +412,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
li.rejected = false;
|
li.rejected = false;
|
||||||
li.arrived_date = null;
|
li.arrived_date = null;
|
||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
|
li.target_project = null;
|
||||||
|
li.target_project_id = null;
|
||||||
});
|
});
|
||||||
} else if (next.status === "accepted") {
|
} else if (next.status === "accepted") {
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
@@ -367,292 +433,396 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<>
|
||||||
<DialogContent className="max-w-3xl">
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogHeader>
|
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
|
||||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
{hasReceipts && (
|
||||||
<div className="field">
|
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber">
|
||||||
<label>تأمینکننده</label>
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
<Combobox
|
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||||
value={supplierName}
|
</div>
|
||||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
)}
|
||||||
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
|
|
||||||
allowAdd
|
|
||||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
|
||||||
placeholder="نام تأمینکننده"
|
|
||||||
onInputChange={(v) => setSupplierName(v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>پروژه</label>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
values={selectedProjects}
|
|
||||||
onChange={setSelectedProjects}
|
|
||||||
options={projectOptions}
|
|
||||||
placeholder="انتخاب پروژه..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field col-span-2">
|
|
||||||
<label>یادداشت</label>
|
|
||||||
<textarea
|
|
||||||
rows={2}
|
|
||||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* اقلام */}
|
{/* اطلاعات بالایی — تکستونه در موبایل، ۲ ستونه در دسکتاپ */}
|
||||||
<div className="border-t border-line pt-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-3">
|
<label className="text-xs font-semibold text-muted">تأمینکننده</label>
|
||||||
{itemRows.map((row) => (
|
<div className="w-full min-w-0">
|
||||||
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
|
<Combobox
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
value={supplierName}
|
||||||
<div className="field">
|
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||||
<label>متریال</label>
|
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
|
||||||
<input
|
allowAdd
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||||
placeholder="متریال"
|
placeholder="نام تأمینکننده"
|
||||||
value={row.cat}
|
onInputChange={(v) => setSupplierName(v)}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">پروژه</label>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<MultiSelectCombobox
|
||||||
|
values={selectedProjects}
|
||||||
|
onChange={setSelectedProjects}
|
||||||
|
options={projectOptions}
|
||||||
|
placeholder="انتخاب پروژه..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||||
|
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* اقلام */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{itemRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{/* متریال — تمامعرض در موبایل */}
|
||||||
|
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">متریال</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
placeholder="متریال"
|
||||||
|
value={row.cat}
|
||||||
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* برند */}
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">برند</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
placeholder="برند"
|
||||||
|
value={row.brand}
|
||||||
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* واحد */}
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">واحد</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
placeholder="واحد"
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مشخصه — تمامعرض در موبایل */}
|
||||||
|
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">مشخصه</label>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<MultiSelectCombobox
|
||||||
|
disabled={hasReceipts}
|
||||||
|
values={row.specs}
|
||||||
|
onChange={(specs) => patchItem(row.key, { specs })}
|
||||||
|
options={specOptions}
|
||||||
|
placeholder="مشخصه"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* تعداد */}
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">تعداد</label>
|
||||||
|
<NumberInput
|
||||||
|
disabled={hasReceipts}
|
||||||
|
value={row.qty}
|
||||||
|
onChange={(qty) => {
|
||||||
|
patchItem(row.key, {
|
||||||
|
qty,
|
||||||
|
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="تعداد"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* قیمت واحد */}
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
|
||||||
|
<NumberInput
|
||||||
|
disabled={hasReceipts}
|
||||||
|
value={row.price}
|
||||||
|
onChange={(price) => {
|
||||||
|
patchItem(row.key, {
|
||||||
|
price,
|
||||||
|
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت واحد"
|
||||||
|
maxDecimals={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* قیمت کل */}
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
|
||||||
|
<NumberInput
|
||||||
|
disabled={hasReceipts}
|
||||||
|
value={row.total}
|
||||||
|
onChange={(t) => {
|
||||||
|
patchItem(row.key, {
|
||||||
|
total: t,
|
||||||
|
price:
|
||||||
|
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت کل"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف */}
|
||||||
|
<div className="col-span-1 flex items-end justify-end pb-0.5">
|
||||||
|
{!hasReceipts && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-xs text-red hover:bg-red-bg/50 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span>حذف قلم</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>برند</label>
|
{/* تخصیص termsheet */}
|
||||||
<input
|
<div className="mt-2.5 border-t border-line/60 pt-2">
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
{row.rejected ? (
|
||||||
placeholder="برند"
|
<label className="ts-cb-label e-restore">
|
||||||
value={row.brand}
|
<input
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
type="checkbox"
|
||||||
/>
|
checked={row.restore}
|
||||||
</div>
|
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||||
<div className="field">
|
/>
|
||||||
<label>مشخصه</label>
|
بازگرداندن به معامله
|
||||||
<MultiSelectCombobox
|
</label>
|
||||||
values={row.specs}
|
) : (
|
||||||
onChange={(specs) => patchItem(row.key, { specs })}
|
<>
|
||||||
options={specOptions}
|
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||||
placeholder="مشخصه"
|
<div className="flex flex-wrap gap-1.5">
|
||||||
/>
|
{tsCards.map((card, idx) => (
|
||||||
</div>
|
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||||||
<div className="field">
|
<input
|
||||||
<label>واحد</label>
|
type="checkbox"
|
||||||
<input
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="me-1 accent-accent disabled:cursor-not-allowed"
|
||||||
placeholder="واحد"
|
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||||
value={row.unit}
|
onChange={(e) => {
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
const next = new Set(row.tsDomIds);
|
||||||
/>
|
const domId = card.domId || card.key;
|
||||||
</div>
|
if (e.target.checked) next.add(domId);
|
||||||
<div className="field">
|
else next.delete(domId);
|
||||||
<label>تعداد</label>
|
patchItem(row.key, { tsDomIds: [...next] });
|
||||||
<NumberInput
|
}}
|
||||||
value={row.qty}
|
/>
|
||||||
onChange={(qty) => {
|
{tsLabel(card, idx)}
|
||||||
patchItem(row.key, {
|
</label>
|
||||||
qty,
|
))}
|
||||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
</div>
|
||||||
});
|
</>
|
||||||
}}
|
)}
|
||||||
placeholder="تعداد"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>قیمت واحد</label>
|
|
||||||
<NumberInput
|
|
||||||
value={row.price}
|
|
||||||
onChange={(price) => {
|
|
||||||
patchItem(row.key, {
|
|
||||||
price,
|
|
||||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="قیمت واحد"
|
|
||||||
maxDecimals={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>قیمت کل</label>
|
|
||||||
<NumberInput
|
|
||||||
value={row.total}
|
|
||||||
onChange={(t) => {
|
|
||||||
patchItem(row.key, {
|
|
||||||
total: t,
|
|
||||||
price:
|
|
||||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="قیمت کل"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
حذف
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* تخصیص termsheet / بازگرداندن */}
|
))}
|
||||||
<div className="mt-2.5 border-t border-line pt-2">
|
</div>
|
||||||
{row.rejected ? (
|
{!hasReceipts && (
|
||||||
<label className="ts-cb-label e-restore">
|
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
|
||||||
<input
|
+ افزودن قلم
|
||||||
type="checkbox"
|
</Button>
|
||||||
checked={row.restore}
|
)}
|
||||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
</div>
|
||||||
|
|
||||||
|
{/* termsheetها */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{tsCards.map((card, idx) => (
|
||||||
|
<div key={card.key} className="variant-card">
|
||||||
|
<div className="variant-head">
|
||||||
|
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||||
|
{!hasReceipts && (
|
||||||
|
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 flex flex-col gap-1.5">
|
||||||
|
{card.milestones.map((ms, i) => (
|
||||||
|
<MilestoneRow
|
||||||
|
key={i}
|
||||||
|
milestone={ms}
|
||||||
|
disabled={hasReceipts}
|
||||||
|
onChange={(nextMs) =>
|
||||||
|
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||||||
|
}
|
||||||
|
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
||||||
/>
|
/>
|
||||||
بازگرداندن به معامله
|
))}
|
||||||
</label>
|
</div>
|
||||||
) : (
|
{!hasReceipts && (
|
||||||
<>
|
<Button
|
||||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
variant="line"
|
||||||
<div className="flex flex-wrap gap-1.5">
|
size="sm"
|
||||||
{tsCards.map((card, idx) => (
|
className="mt-1"
|
||||||
<label key={card.key} className="chip cursor-pointer">
|
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
+ مرحله پرداخت
|
||||||
className="me-1 accent-accent"
|
</Button>
|
||||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = new Set(row.tsDomIds);
|
|
||||||
const domId = card.domId || card.key;
|
|
||||||
if (e.target.checked) next.add(domId);
|
|
||||||
else next.delete(domId);
|
|
||||||
patchItem(row.key, { tsDomIds: [...next] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{tsLabel(card, idx)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
{!hasReceipts && (
|
||||||
|
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
|
||||||
|
+ افزودن termsheet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
|
||||||
+ افزودن قلم
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* termsheetها */}
|
{/* فاکتورها */}
|
||||||
<div className="border-t border-line pt-3">
|
<div className="border-t border-line pt-3">
|
||||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||||
<div className="flex flex-col gap-2">
|
<FileList
|
||||||
{tsCards.map((card, idx) => (
|
files={invoices}
|
||||||
<div key={card.key} className="variant-card">
|
onChange={setInvoices}
|
||||||
<div className="variant-head">
|
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
onDelete={(a) =>
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
a.id &&
|
||||||
×
|
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||||
</button>
|
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||||
</div>
|
)
|
||||||
<div className="mb-1 flex flex-col gap-1.5">
|
}
|
||||||
{card.milestones.map((ms, i) => (
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||||
<MilestoneRow
|
/>
|
||||||
key={i}
|
</div>
|
||||||
milestone={ms}
|
|
||||||
onChange={(nextMs) =>
|
{/* تاریخها و وضعیت */}
|
||||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
<div className="border-t border-line pt-3">
|
||||||
}
|
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
||||||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
/>
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
))}
|
<label className="text-xs font-semibold text-muted">تاریخ پیشنهاد</label>
|
||||||
</div>
|
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||||
<Button
|
</div>
|
||||||
variant="line"
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
size="sm"
|
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
|
||||||
className="mt-1"
|
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
|
||||||
|
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
|
||||||
|
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||||
|
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
value={status}
|
||||||
|
disabled={hasReceipts}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
>
|
>
|
||||||
+ مرحله پرداخت
|
{statusOptionsFor(q.status).map((st) => (
|
||||||
</Button>
|
<option key={st} value={st}>
|
||||||
|
{STATUS_LABEL[st]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{hasReceipts && (
|
||||||
|
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||||||
|
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
|
||||||
+ افزودن termsheet
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* فاکتورها */}
|
{/* فوتر و دکمههای اقدام */}
|
||||||
<div className="border-t border-line pt-3">
|
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
{q.status === "offered" ? (
|
||||||
<FileList
|
<Button
|
||||||
files={invoices}
|
type="button"
|
||||||
onChange={setInvoices}
|
variant="danger"
|
||||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
onDelete={(a) =>
|
className="w-full gap-1.5 sm:w-auto"
|
||||||
a.id &&
|
|
||||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
|
||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* تاریخها و وضعیت */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-2 text-[12px] text-muted">تاریخها و وضعیت:</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ پیشنهاد</label>
|
|
||||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ انقضا</label>
|
|
||||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ پذیرش</label>
|
|
||||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ رسید</label>
|
|
||||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field col-span-2">
|
|
||||||
<label>وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
|
||||||
<select
|
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
value={status}
|
|
||||||
disabled={q.status === "arrived"}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
>
|
>
|
||||||
{statusOptionsFor(q.status).map((st) => (
|
<Trash2 className="h-4 w-4" />
|
||||||
<option key={st} value={st}>
|
<span>حذف پیشنهاد</span>
|
||||||
{STATUS_LABEL[st]}
|
</Button>
|
||||||
</option>
|
) : (
|
||||||
))}
|
<div />
|
||||||
</select>
|
)}
|
||||||
{q.status === "arrived" && (
|
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
|
||||||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
|
||||||
به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.
|
انصراف
|
||||||
</div>
|
</Button>
|
||||||
)}
|
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
|
||||||
|
ذخیره تغییرات
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<DialogFooter>
|
{/* مودال تأیید حذف */}
|
||||||
<Button variant="slate" onClick={onClose}>
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||||
انصراف
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6">
|
||||||
</Button>
|
<DialogHeader>
|
||||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
|
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
||||||
</DialogFooter>
|
</DialogHeader>
|
||||||
</DialogContent>
|
<div className="py-2 text-sm text-muted">
|
||||||
</Dialog>
|
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="slate"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "در حال حذف..." : "حذف نهایی"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,29 +176,60 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */}
|
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 p-3 sm:gap-x-5 sm:gap-y-3 sm:p-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
||||||
<InfoCell label="اقلام">
|
{/* ۱. پروژه — تمامعرض در موبایل */}
|
||||||
|
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||||
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<span className="truncate">
|
||||||
|
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</InfoCell>
|
||||||
|
|
||||||
|
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
||||||
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||||
{summary.count > 1 ? (
|
{summary.count > 1 ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
|
<span className="font-bold text-xs text-ink sm:text-sm">
|
||||||
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
|
{fmtNum(summary.count)} قلم کالا
|
||||||
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
|
</span>
|
||||||
const m = materialById(store.materials, li.material_id);
|
<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]">
|
||||||
return (
|
{store.quotes
|
||||||
<span key={li.id} className="truncate">
|
.find((x) => x.id === q.id)
|
||||||
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
|
?.line_items?.filter((li) => !li.rejected)
|
||||||
</span>
|
.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 && (
|
{(q.line_items?.length || 0) > 3 && (
|
||||||
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
|
<span className="pt-0.5 text-[10px] text-muted">
|
||||||
|
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate">{summary.text}</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unitWarns.map((w) => (
|
{unitWarns.map((w) => (
|
||||||
<Link
|
<Link
|
||||||
key={w.unit}
|
key={w.unit}
|
||||||
@@ -206,7 +237,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||||
>
|
>
|
||||||
<TriangleAlert className="h-2.5 w-2.5" />
|
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||||
واحد جدید: {w.unit}
|
واحد جدید: {w.unit}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -216,15 +247,32 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
<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" />
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
{(() => {
|
||||||
|
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>
|
||||||
</span>
|
</span>
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="مجموع مبلغ">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-extrabold text-ink tabular-nums sm:text-base">
|
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
|
||||||
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
||||||
@@ -234,10 +282,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
)}
|
)}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="شرایط معامله">
|
{/* ۴. شرایط معامله */}
|
||||||
|
<InfoCell label="شرایط معامله" className="col-span-1">
|
||||||
{ts.title !== "—" ? (
|
{ts.title !== "—" ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-xs sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||||
{ts.title}
|
{ts.title}
|
||||||
</div>
|
</div>
|
||||||
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
||||||
@@ -248,12 +297,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</InfoCell>
|
</InfoCell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخها و اکشنها (۲ ستونه در موبایل، نواری در دسکتاپ) */}
|
{/* تاریخها و اکشنها */}
|
||||||
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
||||||
<span className="inline-flex items-center gap-1.5 truncate">
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
<Package className="h-3 w-3 shrink-0" />
|
<Package className="h-3 w-3 shrink-0" />
|
||||||
<span>تاریخ:</span>
|
<span>تاریخ:</span>
|
||||||
<b className="text-ink tabular-nums">{fmtDate(q.quote_date)}</b>
|
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-1.5 truncate">
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
@@ -262,7 +311,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||||
{fmtDate(q.expiry_date)}
|
{fmtDate(q.expiry_date)}
|
||||||
</b>
|
</b>
|
||||||
{expired && <AlarmClock className="h-3 w-3 text-amber shrink-0" aria-label="منقضی" />}
|
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } f
|
|||||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
|
formatMaterialTitle,
|
||||||
itemTermsheetIds,
|
itemTermsheetIds,
|
||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
|
||||||
materialSpecs,
|
|
||||||
refConverted,
|
refConverted,
|
||||||
specsLabel,
|
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
@@ -69,7 +67,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
const total = lineItemTotal(li);
|
const total = lineItemTotal(li);
|
||||||
const projs = itemProjects(q, li);
|
const projs = itemProjects(q, li);
|
||||||
const conv = isRejected ? null : refConverted(store, li);
|
const conv = isRejected ? null : refConverted(store, li);
|
||||||
const specText = specsLabel(materialSpecs(m));
|
|
||||||
|
|
||||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||||
@@ -97,8 +94,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
>
|
>
|
||||||
<div className="fb-name">
|
<div className="fb-name">
|
||||||
<Package className="me-1 inline h-3 w-3 text-muted" />
|
<Package className="me-1 inline h-3 w-3 text-muted" />
|
||||||
{materialLabel(m)}
|
{formatMaterialTitle(m)}
|
||||||
{specText ? ` · ${specText}` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="fb-meta">
|
<div className="fb-meta">
|
||||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { Link } from "react-router-dom";
|
|||||||
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
|
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
|
||||||
import type { Quote, Store } from "../../lib/types";
|
import type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
|
formatMaterialTitle,
|
||||||
isExpired,
|
isExpired,
|
||||||
itemsSummary,
|
itemsSummary,
|
||||||
materialById,
|
materialById,
|
||||||
materialSpecs,
|
|
||||||
projectCell,
|
projectCell,
|
||||||
quoteTotal,
|
quoteTotal,
|
||||||
supplierById,
|
supplierById,
|
||||||
@@ -92,11 +92,10 @@ export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
|
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
|
||||||
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
||||||
const m = materialById(store.materials, li.material_id);
|
const m = materialById(store.materials, li.material_id);
|
||||||
const specs = materialSpecs(m);
|
|
||||||
return (
|
return (
|
||||||
<span key={li.id} className="flex items-center gap-1">
|
<span key={li.id} className="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
||||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
{formatMaterialTitle(m)}
|
||||||
<span className="text-[10px] text-muted/70">
|
<span className="text-[10px] text-muted/70">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { uniqueUnits } from "../../lib/units";
|
|||||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
type FormType = TransactionType | "TRANSFER";
|
type FormType = TransactionType | "TRANSFER";
|
||||||
|
|
||||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||||||
// رنگبندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابهجایی/شمارش
|
|
||||||
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپشده هنگام ثبت بهصورت خودکار در کاتالوگ سراسری ساخته میشود. ثبت حداقل یک مشخصه فنی الزامی است." },
|
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپشده هنگام ثبت بهصورت خودکار در کاتالوگ سراسری ساخته میشود. ثبت حداقل یک مشخصه فنی الزامی است." },
|
||||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||||
@@ -46,7 +46,6 @@ function jalDateLabel(d: Date): string {
|
|||||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// تغییر موجودی — پورت کامل صفحهی ارسالی (همه انواع تراکنش)
|
|
||||||
export function ChangeInventoryPage() {
|
export function ChangeInventoryPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
@@ -60,15 +59,12 @@ export function ChangeInventoryPage() {
|
|||||||
const [brandTxt, setBrandTxt] = useState("");
|
const [brandTxt, setBrandTxt] = useState("");
|
||||||
const [unitTxt, setUnitTxt] = useState("");
|
const [unitTxt, setUnitTxt] = useState("");
|
||||||
const [qty, setQty] = useState("");
|
const [qty, setQty] = useState("");
|
||||||
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
|
|
||||||
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
||||||
// مشخصههای فنی — چیپهای چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
|
|
||||||
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// prefill از URL (پرش از کنترل روزانه)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = params.get("type");
|
const t = params.get("type");
|
||||||
const w = params.get("wh");
|
const w = params.get("wh");
|
||||||
@@ -91,7 +87,6 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
const formRecords = useMemo(() => {
|
const formRecords = useMemo(() => {
|
||||||
const txt = matTxt.trim();
|
const txt = matTxt.trim();
|
||||||
// انتقال از انبار مبدأ استفاده میکند، بقیه از انبار انتخابی
|
|
||||||
const wid = type === "TRANSFER" ? fromId : whId;
|
const wid = type === "TRANSFER" ? fromId : whId;
|
||||||
return data.inventory.filter((r) => {
|
return data.inventory.filter((r) => {
|
||||||
if (r.warehouse?.id !== wid) return false;
|
if (r.warehouse?.id !== wid) return false;
|
||||||
@@ -105,8 +100,6 @@ export function ChangeInventoryPage() {
|
|||||||
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
||||||
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
||||||
|
|
||||||
// تبدیل امن تاریخ فرم به ISO — اگر فیلد خالی/نامعتبر بود (که قبلاً submit را
|
|
||||||
// بیصدا میشکست) به «هماکنون» برمیگردد تا ثبت هیچوقت قفل نشود.
|
|
||||||
const toServerIso = () => {
|
const toServerIso = () => {
|
||||||
try {
|
try {
|
||||||
const d = new Date(dateInput);
|
const d = new Date(dateInput);
|
||||||
@@ -115,8 +108,6 @@ export function ChangeInventoryPage() {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// هر چیپ یک مشخصه است؛ در specs انبار بهصورت کلید با مقدار خالی ذخیره میشود
|
|
||||||
// تا رکوردهای انبارِ دارای مشخصههای متفاوت از هم جدا بمانند
|
|
||||||
const specsFromChips = () => {
|
const specsFromChips = () => {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
specsChips.forEach((c) => {
|
specsChips.forEach((c) => {
|
||||||
@@ -151,9 +142,6 @@ export function ChangeInventoryPage() {
|
|||||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||||
}, [data.transactions, whId, matTxt]);
|
}, [data.transactions, whId, matTxt]);
|
||||||
|
|
||||||
// --- پیدا شده: ماندهی مفقودی هر ردیف = مفقودی − مجموع پیداشدههای قبلیِ همان رکورد
|
|
||||||
const lossKey = (t: InventoryTransaction) =>
|
|
||||||
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
|
||||||
const foundRemaining = useMemo(() => {
|
const foundRemaining = useMemo(() => {
|
||||||
const foundByKey = new Map<string, number>();
|
const foundByKey = new Map<string, number>();
|
||||||
data.transactions
|
data.transactions
|
||||||
@@ -170,7 +158,9 @@ export function ChangeInventoryPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [lossTxs, data.transactions, whId, matTxt]);
|
}, [lossTxs, data.transactions, whId, matTxt]);
|
||||||
|
|
||||||
// مفقودیهای کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف میشوند
|
const lossKey = (t: InventoryTransaction) =>
|
||||||
|
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
||||||
|
|
||||||
const pendingLosses = useMemo(
|
const pendingLosses = useMemo(
|
||||||
() =>
|
() =>
|
||||||
lossTxs
|
lossTxs
|
||||||
@@ -188,7 +178,6 @@ export function ChangeInventoryPage() {
|
|||||||
try {
|
try {
|
||||||
for (const op of ops) await postOne(op);
|
for (const op of ops) await postOne(op);
|
||||||
toast.success("ثبت شد ✓");
|
toast.success("ثبت شد ✓");
|
||||||
// اعلام به تبهای دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه میکنند)
|
|
||||||
notifyDataChanged("inventory");
|
notifyDataChanged("inventory");
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setMatTxt("");
|
setMatTxt("");
|
||||||
@@ -205,7 +194,6 @@ export function ChangeInventoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- submit handlers ----------
|
|
||||||
const submitPurchase = async () => {
|
const submitPurchase = async () => {
|
||||||
const matName = matTxt.trim();
|
const matName = matTxt.trim();
|
||||||
const brandName = brandTxt.trim();
|
const brandName = brandTxt.trim();
|
||||||
@@ -216,10 +204,9 @@ export function ChangeInventoryPage() {
|
|||||||
if (!brandName) return toast.error("نام برند را وارد کنید");
|
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||||||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
|
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ساختِ بههنگام موجودیتهای ناشناخته در کاتالوگ سراسری (بدون پیشنیاز تدارکات)
|
|
||||||
let uid = unitIdByName(unitName);
|
let uid = unitIdByName(unitName);
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
const created = await apiV26.catalog.createUnit(unitName);
|
const created = await apiV26.catalog.createUnit(unitName);
|
||||||
@@ -264,7 +251,6 @@ export function ChangeInventoryPage() {
|
|||||||
await postSeq(ops);
|
await postSeq(ops);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- شمارش موجودی: پیشنمایش اختلاف + تأیید قبل از ثبت ----------
|
|
||||||
interface CountConfirmRow {
|
interface CountConfirmRow {
|
||||||
material: string;
|
material: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -358,7 +344,7 @@ export function ChangeInventoryPage() {
|
|||||||
if (!(n > 0)) return null;
|
if (!(n > 0)) return null;
|
||||||
const item = pendingLosses[i];
|
const item = pendingLosses[i];
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
if (n > item.remaining) return null; // سقف سمت کلاینت (بکاند هم رد میکند)
|
if (n > item.remaining) return null;
|
||||||
const t = item.tx;
|
const t = item.tx;
|
||||||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||||||
})
|
})
|
||||||
@@ -382,12 +368,10 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
const canSubmit = (): boolean => {
|
const canSubmit = (): boolean => {
|
||||||
if (type === "PURCHASE") {
|
if (type === "PURCHASE") {
|
||||||
// نامهای تایپشده لازم نیست از قبل در فهرست باشند — در submit ساخته میشوند
|
|
||||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
||||||
}
|
}
|
||||||
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||||
// پیدا شده: هر مقدار واردشده باید داخل ماندهی مفقودی همان ردیف باشد
|
|
||||||
if (type === "FOUND") {
|
if (type === "FOUND") {
|
||||||
return !!(
|
return !!(
|
||||||
whId &&
|
whId &&
|
||||||
@@ -402,7 +386,6 @@ export function ChangeInventoryPage() {
|
|||||||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
// sync rows when the record list changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === "RETURN") {
|
if (type === "RETURN") {
|
||||||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||||
@@ -447,54 +430,68 @@ export function ChangeInventoryPage() {
|
|||||||
const isTransfer = type === "TRANSFER";
|
const isTransfer = type === "TRANSFER";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen overflow-x-hidden pb-20">
|
||||||
<Header
|
<Header
|
||||||
title="تغییر موجودی"
|
title="تغییر موجودی"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
backTo="/inventory"
|
backTo="/inventory"
|
||||||
backLabel="بازگشت به داشبورد"
|
backLabel="بازگشت به داشبورد"
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||||
{/* انتخاب نوع تراکنش */}
|
{/* انتخاب نوع تغییر موجودی — چیپهای لمسی مرتب در موبایل */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-3">
|
<div className="rounded-xl border border-line bg-panel p-3 w-full shadow-2xs">
|
||||||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap">
|
||||||
{FORM_TYPES.map((f) => (
|
{FORM_TYPES.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.t}
|
key={f.t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||||||
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2 text-xs sm:text-[12.5px] text-start sm:text-center transition-all",
|
||||||
|
type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg"
|
||||||
|
)}
|
||||||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||||||
>
|
>
|
||||||
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
|
<div>{f.label}</div>
|
||||||
|
<span className="text-[10px] opacity-80">({f.dir})</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
<p className="mt-2.5 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||||||
{/* انتخاب انبارها */}
|
{/* انتخاب انبارها (چیپهای لمسی به جای سلکت خام) */}
|
||||||
{!isTransfer ? (
|
{!isTransfer ? (
|
||||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
<div className="mb-3 flex flex-col gap-3">
|
||||||
<div className="field">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label>انبار *</label>
|
<label className="text-xs font-semibold text-muted">انبار *</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5">
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
{whOpts.map((w) => (
|
||||||
value={whId}
|
<button
|
||||||
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
|
key={w.id}
|
||||||
>
|
type="button"
|
||||||
<option value="">— انتخاب کنید —</option>
|
onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
|
||||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
className={cn(
|
||||||
</select>
|
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
whId === w.id
|
||||||
|
? "bg-accent text-white font-bold shadow-xs"
|
||||||
|
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||||
<input
|
<input
|
||||||
list="dl-mat"
|
list="dl-mat"
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||||||
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
||||||
value={matTxt}
|
value={matTxt}
|
||||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||||
@@ -508,33 +505,51 @@ export function ChangeInventoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>از (انبار مبدأ) *</label>
|
<label className="text-xs font-semibold text-muted">از (انبار مبدأ) *</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
{whOpts.map((w) => (
|
||||||
value={fromId}
|
<button
|
||||||
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
|
key={w.id}
|
||||||
>
|
type="button"
|
||||||
<option value="">— انتخاب انبار مبدأ —</option>
|
onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
|
||||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
className={cn(
|
||||||
</select>
|
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
fromId === w.id
|
||||||
|
? "bg-accent text-white font-bold shadow-xs"
|
||||||
|
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>به (انبار مقصد) *</label>
|
<label className="text-xs font-semibold text-muted">به (انبار مقصد) *</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
{whOpts.filter((w) => w.id !== fromId).map((w) => (
|
||||||
value={toId}
|
<button
|
||||||
disabled={!fromId}
|
key={w.id}
|
||||||
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
|
type="button"
|
||||||
>
|
disabled={!fromId}
|
||||||
<option value="">— انتخاب انبار مقصد —</option>
|
onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
|
||||||
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
className={cn(
|
||||||
</select>
|
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-40",
|
||||||
|
toId === w.id
|
||||||
|
? "bg-accent text-white font-bold shadow-xs"
|
||||||
|
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>متریال *</label>
|
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||||
<input
|
<input
|
||||||
list="dl-mat2"
|
list="dl-mat2"
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||||||
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
||||||
value={matTxt}
|
value={matTxt}
|
||||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||||
@@ -550,7 +565,7 @@ export function ChangeInventoryPage() {
|
|||||||
{type === "PURCHASE" && (
|
{type === "PURCHASE" && (
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>متریال *</label>
|
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={matTxt}
|
value={matTxt}
|
||||||
items={data.lookup.materials.map((mm) => mm.name)}
|
items={data.lookup.materials.map((mm) => mm.name)}
|
||||||
@@ -567,7 +582,7 @@ export function ChangeInventoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>برند *</label>
|
<label className="text-xs font-semibold text-muted">برند *</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={brandTxt}
|
value={brandTxt}
|
||||||
items={data.lookup.brands.map((b) => b.name)}
|
items={data.lookup.brands.map((b) => b.name)}
|
||||||
@@ -579,7 +594,7 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>واحد *</label>
|
<label className="text-xs font-semibold text-muted">واحد *</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={unitTxt}
|
value={unitTxt}
|
||||||
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
||||||
@@ -591,17 +606,17 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تعداد *</label>
|
<label className="text-xs font-semibold text-muted">تعداد *</label>
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
|
||||||
placeholder="مثلاً ۲۰۰"
|
placeholder="مثلاً ۲۰۰"
|
||||||
value={qty}
|
value={qty}
|
||||||
onChange={(e) => setQty(e.target.value)}
|
onChange={(e) => setQty(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تاریخ و زمان (شمسی)</label>
|
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={dateInput}
|
value={dateInput}
|
||||||
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
||||||
@@ -610,7 +625,7 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>مشخصههای فنی *</label>
|
<label className="text-xs font-semibold text-muted">مشخصههای فنی *</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={specsChips}
|
values={specsChips}
|
||||||
onChange={setSpecsChips}
|
onChange={setSpecsChips}
|
||||||
@@ -619,9 +634,9 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>یادداشت</label>
|
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
@@ -647,12 +662,10 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* برگشت — برداشتهای امروز */}
|
|
||||||
{type === "RETURN" && (
|
{type === "RETURN" && (
|
||||||
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* انتقال */}
|
|
||||||
{type === "TRANSFER" && (
|
{type === "TRANSFER" && (
|
||||||
<RowTable
|
<RowTable
|
||||||
isCount={false}
|
isCount={false}
|
||||||
@@ -669,7 +682,6 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پیدا شده — مفقودیها (با سقف: پیدا شده ≤ ماندهی مفقودی) */}
|
|
||||||
{type === "FOUND" && (
|
{type === "FOUND" && (
|
||||||
<FoundTable
|
<FoundTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -681,13 +693,13 @@ export function ChangeInventoryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* دکمه ثبت */}
|
{/* دکمه ثبت */}
|
||||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
<div className="mt-4 flex flex-col-reverse sm:flex-row items-center justify-between gap-3 border-t border-line pt-3">
|
||||||
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
<span className="text-[11.5px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canSubmit() || busy}
|
disabled={!canSubmit() || busy}
|
||||||
onClick={doSubmit}
|
onClick={doSubmit}
|
||||||
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
|
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
|
||||||
style={{ background: meta.color }}
|
style={{ background: meta.color }}
|
||||||
>
|
>
|
||||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||||
@@ -695,10 +707,10 @@ export function ChangeInventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
|
{/* مودال تأیید شمارش موجودی */}
|
||||||
{countConfirm && (
|
{countConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
||||||
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
|
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
|
||||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||||
موجودی هر ردیف به «تعداد شمارششده» اصلاح میشود و اختلاف (کسری/مازاد) ثبت خواهد شد.
|
موجودی هر ردیف به «تعداد شمارششده» اصلاح میشود و اختلاف (کسری/مازاد) ثبت خواهد شد.
|
||||||
@@ -767,7 +779,6 @@ export function ChangeInventoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||||
|
|
||||||
isCount: boolean;
|
isCount: boolean;
|
||||||
rows: RowDraft[];
|
rows: RowDraft[];
|
||||||
setRows: (r: RowDraft[]) => void;
|
setRows: (r: RowDraft[]) => void;
|
||||||
@@ -778,7 +789,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
|||||||
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px] min-w-[500px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">مشخصهها</th>
|
<th className="py-2 pe-3">مشخصهها</th>
|
||||||
@@ -792,22 +803,22 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{records.map((r, i) => (
|
{records.map((r, i) => (
|
||||||
<tr key={r.id} className="border-b border-line/50">
|
<tr key={r.id} className="border-b border-line/50">
|
||||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
|
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
|
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||||||
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-2 pe-3">
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
placeholder={isCount ? "شمارششده" : "۰"}
|
placeholder={isCount ? "شمارششده" : "۰"}
|
||||||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-2">
|
||||||
<input
|
<input
|
||||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
placeholder="توضیح (اختیاری)"
|
placeholder="توضیح (اختیاری)"
|
||||||
value={rows[i]?.note || ""}
|
value={rows[i]?.note || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
@@ -817,7 +828,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
|
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -831,10 +842,10 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
|||||||
unitNameFn: (id: number | null | undefined) => string;
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
}) {
|
}) {
|
||||||
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
||||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
|
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است.</div>;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px] min-w-[500px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">مشخصهها</th>
|
<th className="py-2 pe-3">مشخصهها</th>
|
||||||
@@ -848,22 +859,22 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{txs.map((t, i) => (
|
{txs.map((t, i) => (
|
||||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
<td className="py-2 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-2 pe-3">
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
placeholder="۰"
|
placeholder="۰"
|
||||||
value={rows[i]?.qty || ""}
|
value={rows[i]?.qty || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-2">
|
||||||
<input
|
<input
|
||||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
placeholder="توضیح برگشت"
|
placeholder="توضیح برگشت"
|
||||||
value={rows[i]?.note || ""}
|
value={rows[i]?.note || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
@@ -885,17 +896,17 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
|||||||
unitNameFn: (id: number | null | undefined) => string;
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
}) {
|
}) {
|
||||||
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
||||||
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
|
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای در این انبار وجود ندارد.</div>;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px] min-w-[550px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">متریال</th>
|
<th className="py-2 pe-3">متریال</th>
|
||||||
<th className="py-2 pe-3">مشخصهها</th>
|
<th className="py-2 pe-3">مشخصهها</th>
|
||||||
<th className="py-2 pe-3">برند</th>
|
<th className="py-2 pe-3">برند</th>
|
||||||
<th className="py-2 pe-3">واحد</th>
|
<th className="py-2 pe-3">واحد</th>
|
||||||
<th className="py-2 pe-3">ماندهی مفقودی</th>
|
<th className="py-2 pe-3">مانده مفقودی</th>
|
||||||
<th className="py-2 pe-3">تعداد پیداشده</th>
|
<th className="py-2 pe-3">تعداد پیداشده</th>
|
||||||
<th className="py-2">یادداشت</th>
|
<th className="py-2">یادداشت</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -906,28 +917,26 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
|||||||
const over = entered > remaining;
|
const over = entered > remaining;
|
||||||
return (
|
return (
|
||||||
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
|
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
|
||||||
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
<td className="py-2 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||||
<td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
<td className="py-2 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-2 pe-3">
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
|
className={`h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
|
||||||
placeholder="۰"
|
placeholder="۰"
|
||||||
value={rows[i]?.qty || ""}
|
value={rows[i]?.qty || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
{over && (
|
{over && (
|
||||||
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
|
<div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>
|
||||||
تعداد پیداشده نمیتواند بیشتر از مقدار مفقودی باشد.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-2">
|
||||||
<input
|
<input
|
||||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
placeholder="توضیح"
|
placeholder="توضیح"
|
||||||
value={rows[i]?.note || ""}
|
value={rows[i]?.note || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
@@ -942,8 +951,7 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
|
|
||||||
function parseNumLocal(s: string): number {
|
function parseNumLocal(s: string): number {
|
||||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
return isFinite(v) ? v : 0;
|
return isFinite(v) ? v : 0;
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X } from "lucide-react";
|
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Badge } from "../../components/ui/badge";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
@@ -14,7 +13,7 @@ import { faNum } from "../../lib/formatters";
|
|||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
// رنگبندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابهجایی
|
// رنگبندی معنایی تراکنشها
|
||||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||||
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
||||||
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
||||||
@@ -148,24 +147,23 @@ export function InventoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-24 md:pb-6">
|
<div className="min-h-screen pb-24 md:pb-6 overflow-x-hidden">
|
||||||
<Header
|
<Header
|
||||||
title="سامانه مدیریت انبار"
|
title="سامانه مدیریت انبار"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
||||||
actions={
|
actions={
|
||||||
/* تبهای ناوبری فقط در دسکتاپ — دکمه بالایی به طور کامل حذف شد */
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||||
{/* نوار فیلتر و جستجوی واکنشگرا */}
|
{/* جعبه فیلترها و جستجو */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
|
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3 w-full shadow-2xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
|
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
|
||||||
placeholder="جستجوی نام متریال…"
|
placeholder="جستجوی نام متریال…"
|
||||||
@@ -187,8 +185,10 @@ export function InventoryPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowMobileFilters((s) => !s)}
|
onClick={() => setShowMobileFilters((s) => !s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors md:hidden",
|
"inline-flex shrink-0 h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||||
hasFilter ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel text-ink hover:bg-bg"
|
showMobileFilters || hasFilter
|
||||||
|
? "border-accent bg-accent/10 text-accent font-bold"
|
||||||
|
: "border-line bg-panel text-ink hover:bg-bg"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||||
@@ -199,7 +199,7 @@ export function InventoryPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
"inline-flex shrink-0 h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
||||||
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
|
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
|
||||||
)}
|
)}
|
||||||
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
||||||
@@ -210,83 +210,186 @@ export function InventoryPage() {
|
|||||||
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Badge variant="outline" className="hidden sm:inline-flex h-9 items-center px-2.5 text-xs">
|
|
||||||
{faNum(groups.length)} قلم
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* گزینههای فیلتر پیشرفته */}
|
{/* فیلتر سریع انبارها (چیپهای افقی لمسی) */}
|
||||||
<div
|
{visibleWarehouses.length > 1 && (
|
||||||
className={cn(
|
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto pb-1 pt-0.5 [scrollbar-width:none]">
|
||||||
"mt-3 grid grid-cols-1 gap-2.5 border-t border-line/60 pt-3 sm:grid-cols-2 md:flex md:flex-wrap md:items-end",
|
|
||||||
!showMobileFilters && "hidden md:flex"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="field flex-1 min-w-[140px]">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">دستهبندی</label>
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
|
||||||
>
|
|
||||||
<option value="">همه دستهبندیها</option>
|
|
||||||
{lookup.categories.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="field flex-1 min-w-[140px]">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">پروژه</label>
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
|
||||||
value={filters.project}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
|
||||||
>
|
|
||||||
<option value="">همه پروژهها</option>
|
|
||||||
{lookup.projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="field flex-1 min-w-[140px]">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">انبار</label>
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
|
||||||
value={filters.warehouse}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
|
||||||
>
|
|
||||||
<option value="">همه انبارها</option>
|
|
||||||
{visibleWarehouses.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasFilter && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-xs text-red hover:bg-red/10 transition-colors"
|
onClick={() => setFilters((f) => ({ ...f, warehouse: "" }))}
|
||||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
className={cn(
|
||||||
|
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
!filters.warehouse
|
||||||
|
? "bg-accent text-white shadow-xs font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
✕ پاک کردن فیلترها
|
همه انبارها
|
||||||
</button>
|
</button>
|
||||||
)}
|
{visibleWarehouses.map((w) => (
|
||||||
|
<button
|
||||||
<div className="text-[11px] text-muted ms-auto sm:hidden">
|
key={w.id}
|
||||||
نمایش {faNum(groups.length)} قلم کالا
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, warehouse: f.warehouse === w.id ? "" : w.id }))}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
filters.warehouse === w.id
|
||||||
|
? "bg-accent text-white shadow-xs font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||||
|
{showMobileFilters && (
|
||||||
|
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||||
|
{/* دستهبندیها */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
!filters.category
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
همه دستهبندیها
|
||||||
|
</button>
|
||||||
|
{lookup.categories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
filters.category === c.id
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* پروژهها (مخصوص ادمین) */}
|
||||||
|
{isAdmin && lookup.projects.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, project: "" }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
!filters.project
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
همه پروژهها
|
||||||
|
</button>
|
||||||
|
{lookup.projects.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, project: f.project === p.id ? "" : p.id }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
filters.project === p.id
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمه پاک کردن فیلترها */}
|
||||||
|
{hasFilter && (
|
||||||
|
<div className="pt-1 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||||
|
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
پاک کردن فیلترها
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
نمایش {faNum(groups.length)} قلم کالا
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جدول موجودی انبار با اسکرول افقی محافظتشده */}
|
{/* ۱. نمای مخصوص موبایل: کارتهای عمودی کاملاً ریسپانسیو */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
<div className="flex flex-col gap-2.5 md:hidden w-full">
|
||||||
|
{!groups.length ? (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
|
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => {
|
||||||
|
const isExp = expandedKey === g.key;
|
||||||
|
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||||
|
return (
|
||||||
|
<InventoryMobileCard
|
||||||
|
key={g.key}
|
||||||
|
g={g}
|
||||||
|
isExp={isExp}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
matName={materialName(lookup.materials, g.matId)}
|
||||||
|
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||||
|
wh={whName(warehouses, g.whId)}
|
||||||
|
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||||
|
unit={unitName(lookup.units, g.unitId)}
|
||||||
|
onToggle={() => {
|
||||||
|
setExpandedKey(isExp ? null : g.key);
|
||||||
|
setSelTx(null);
|
||||||
|
setExpBrand(null);
|
||||||
|
}}
|
||||||
|
timeline={
|
||||||
|
isExp ? (
|
||||||
|
<Timeline
|
||||||
|
g={g}
|
||||||
|
txs={groupTxs(g)}
|
||||||
|
allTxs={transactions}
|
||||||
|
brandTxs={brandTxs}
|
||||||
|
selTx={selTx}
|
||||||
|
setSelTx={setSelTx}
|
||||||
|
expBrand={expBrand}
|
||||||
|
setExpBrand={setExpBrand}
|
||||||
|
matName={materialName(lookup.materials, g.matId)}
|
||||||
|
unit={unitName(lookup.units, g.unitId)}
|
||||||
|
brandNameFn={(id) => brandName(lookup.brands, id)}
|
||||||
|
whNameFn={(id) => whName(warehouses, id)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ۲. نمای دسکتاپ: جدول سنتی */}
|
||||||
|
<div className="hidden md:block overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||||
{!groups.length ? (
|
{!groups.length ? (
|
||||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||||
) : (
|
) : (
|
||||||
@@ -349,14 +452,9 @@ export function InventoryPage() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* متن راهنما فقط در دسکتاپ نمایش داده میشود */}
|
|
||||||
<div className="hidden md:block text-[11px] text-muted">
|
|
||||||
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور تغییر موجودی (FAB دایرهای آبی با علامت جمع) */}
|
{/* دکمه شناور تغییر موجودی */}
|
||||||
{isPM && (
|
{isPM && (
|
||||||
<Link
|
<Link
|
||||||
to="/inventory/change"
|
to="/inventory/change"
|
||||||
@@ -371,6 +469,111 @@ export function InventoryPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// کامپوننت کارت انبار در موبایل
|
||||||
|
function InventoryMobileCard({
|
||||||
|
g,
|
||||||
|
isExp,
|
||||||
|
isAdmin,
|
||||||
|
matName,
|
||||||
|
matCatName,
|
||||||
|
wh,
|
||||||
|
projName,
|
||||||
|
unit,
|
||||||
|
onToggle,
|
||||||
|
timeline,
|
||||||
|
}: {
|
||||||
|
g: Group;
|
||||||
|
isExp: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
matName: string;
|
||||||
|
matCatName: string;
|
||||||
|
wh: string;
|
||||||
|
projName: string;
|
||||||
|
unit: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
timeline: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const specsText = Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border bg-panel transition-all shadow-2xs w-full",
|
||||||
|
isExp ? "border-accent/60 ring-1 ring-accent/20" : "border-line hover:border-line/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div onClick={onToggle} className="cursor-pointer p-3 active:bg-bg/40 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||||
|
{matName}
|
||||||
|
</h3>
|
||||||
|
{matCatName !== "—" && (
|
||||||
|
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
||||||
|
{matCatName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-bg text-muted transition-transform duration-200",
|
||||||
|
isExp && "rotate-180 bg-accent/10 text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کادر شاخص موجودی و انبار */}
|
||||||
|
<div className="my-2.5 flex items-center justify-between gap-3 rounded-lg border border-line/60 bg-bg/50 p-2.5">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-semibold text-muted">موجودی انبار</span>
|
||||||
|
<div className="flex items-baseline gap-1 pt-0.5">
|
||||||
|
<span className="text-lg font-black tabular-nums text-ink">
|
||||||
|
{faNum(g.qty)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-medium text-muted">{unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end text-end min-w-0">
|
||||||
|
<span className="text-[10px] font-semibold text-muted">محل نگهداری</span>
|
||||||
|
<div className="flex items-center gap-1 text-xs font-bold text-accent truncate pt-0.5">
|
||||||
|
<WarehouseIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{wh}</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && projName && (
|
||||||
|
<span className="truncate text-[10px] text-muted max-w-[130px]">{projName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مشخصات فنی و تاریخ */}
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[10.5px] text-muted pt-0.5">
|
||||||
|
<div className="min-w-0 flex-1 truncate">
|
||||||
|
{specsText ? (
|
||||||
|
<span className="rounded bg-bg px-1.5 py-0.5 text-[10px] font-medium text-ink">
|
||||||
|
{specsText}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted/60">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{isoToJalaliStr(g.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExp && (
|
||||||
|
<div className="border-t border-line/70 bg-accent-bg/10 p-3">
|
||||||
|
{timeline}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||||
g: Group;
|
g: Group;
|
||||||
isExp: boolean;
|
isExp: boolean;
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
<div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
|
||||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={supplierName}
|
value={supplierName}
|
||||||
|
|||||||
@@ -41,21 +41,30 @@ export function itemRowLabel(it: ItemDraft): string {
|
|||||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام پایهی متریال از نامِ ذخیرهشده
|
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||||
function materialBaseName(name: string, brand: string, specs: string[]): string {
|
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
|
||||||
let base = (name || "").trim();
|
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||||
if (!base) return "";
|
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
||||||
if (brand) {
|
// نامِ پایه باقی بماند.
|
||||||
if (base.startsWith(brand)) base = base.slice(brand.length);
|
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
||||||
else if (base.includes(brand)) base = base.split(brand).join(" ");
|
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;
|
||||||
}
|
}
|
||||||
specs
|
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||||
.filter((s) => s && s.trim())
|
if (parts.some((p) => p === base)) return fallback;
|
||||||
.forEach((s) => {
|
return base || fallback;
|
||||||
if (base.includes(s)) base = base.split(s).join(" ");
|
|
||||||
});
|
|
||||||
base = base.replace(/\s+/g, " ").trim();
|
|
||||||
return base || (name || "").trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
// مقادیر مجزای category/brand/spec داخل تاپیک
|
||||||
@@ -66,10 +75,10 @@ export function materialOptions(
|
|||||||
field: "cat" | "brand" | "spec",
|
field: "cat" | "brand" | "spec",
|
||||||
allItems: ItemDraft[] = [],
|
allItems: ItemDraft[] = [],
|
||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
const brand = item.brand.trim();
|
|
||||||
|
|
||||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
@@ -87,15 +96,21 @@ export function materialOptions(
|
|||||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
|
||||||
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 (brand) list = list.filter((m) => normalize(m.brand) === normalize(brand));
|
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||||
const base = new Set(list.flatMap((m) => (m.specs || []).filter(Boolean)));
|
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
||||||
|
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
||||||
|
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
||||||
|
const base = new Set<string>();
|
||||||
|
apiSpecs.forEach((s) => s && base.add(s));
|
||||||
|
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||||
|
list.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||||
fromRows((it) => it.specs).forEach((s) => base.add(s));
|
fromRows((it) => it.specs).forEach((s) => base.add(s));
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
@@ -119,13 +134,14 @@ 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);
|
const base = materialBaseName(m.name, m.brand, m.specs, m.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"));
|
||||||
}, [materials]);
|
}, [materials]);
|
||||||
|
|
||||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||||
|
const [specOptions, setSpecOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -141,6 +157,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
.brandsList()
|
.brandsList()
|
||||||
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
void api
|
||||||
|
.specsList()
|
||||||
|
.then((list) => alive && setSpecOptions(list))
|
||||||
|
.catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -201,7 +221,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
<span>قیمت کل</span>
|
<span>قیمت کل</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div data-field="line_items" className="flex flex-col gap-2">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -231,7 +251,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={it.specs}
|
values={it.specs}
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
options={materialOptions(materials, selectedTopic, it, "spec", items)}
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||||
placeholder="مشخصه"
|
placeholder="مشخصه"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div data-field="termsheets" className="flex flex-col gap-3">
|
||||||
{cards.map((card, idx) => (
|
{cards.map((card, idx) => (
|
||||||
<TermsheetCard
|
<TermsheetCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-field="project">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
<h3 className="mb-1 text-[15px] font-bold">
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
||||||
پروژهها
|
پروژهها
|
||||||
|
|||||||
@@ -57,20 +57,20 @@ export function Step5Review({
|
|||||||
|
|
||||||
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
||||||
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
||||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
|
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
|
||||||
<label>تاریخ پیشنهاد</label>
|
<label>تاریخ پیشنهاد</label>
|
||||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||||
<div className="hint">پیشفرض امروز</div>
|
<div className="hint">پیشفرض امروز</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
||||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
|
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
|
||||||
<label>تاریخ انقضای پیشنهاد</label>
|
<label>تاریخ انقضای پیشنهاد</label>
|
||||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field col-span-12">
|
<div className="field col-span-12" data-field="notes">
|
||||||
<label>یادداشت (اختیاری)</label>
|
<label>یادداشت (اختیاری)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -81,7 +81,7 @@ export function Step5Review({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field col-span-12">
|
<div className="field col-span-12" data-field="proforma">
|
||||||
<label>پیشفاکتور (اختیاری)</label>
|
<label>پیشفاکتور (اختیاری)</label>
|
||||||
{proforma ? (
|
{proforma ? (
|
||||||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { LineItem, Quote } from "../../lib/types";
|
import type { LineItem, Quote } from "../../lib/types";
|
||||||
import { api, nextId } from "../../lib/api";
|
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
useAddProject,
|
useAddProject,
|
||||||
@@ -125,16 +125,8 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {
|
||||||
void addMaterial.mutateAsync({
|
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||||
id: nextId("m", materials),
|
|
||||||
name: [rec.brand, rec.category, ...rec.specs].filter(Boolean).join(" ") || "کالای جدید",
|
|
||||||
topic: rec.topic,
|
|
||||||
category: rec.category,
|
|
||||||
brand: rec.brand,
|
|
||||||
specs: rec.specs,
|
|
||||||
base_unit: "",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||||
@@ -193,19 +185,72 @@ export function WizardPage() {
|
|||||||
|
|
||||||
// ---------- ذخیره ----------
|
// ---------- ذخیره ----------
|
||||||
const doSave = async () => {
|
const doSave = async () => {
|
||||||
const sectionError = (step: number, title: string, err: unknown) => {
|
const resolveErrorTarget = (err: unknown): { step: number; field?: string } => {
|
||||||
|
let field = "";
|
||||||
|
let msg = "";
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
field = err.field || "";
|
||||||
|
msg = err.message || "";
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
msg = err.message || "";
|
||||||
|
if ("field" in err && typeof (err as any).field === "string") {
|
||||||
|
field = (err as any).field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
|
// ۱. تأمینکننده
|
||||||
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
|
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
||||||
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
|
return { step: 2, field: field || "line_items" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
||||||
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
|
return { step: 3, field: field || "termsheets" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۴. پروژه
|
||||||
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
|
// ۵. تاریخها، یادداشت، پیشفاکتور
|
||||||
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
|
if (/notes|یادداشت/i.test(combined)) return { step: 5, field: "notes" };
|
||||||
|
|
||||||
|
return { step: 5, field };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionError = (stepOrTarget: number | { step: number; field?: string }, title: string, err: unknown) => {
|
||||||
|
const target = typeof stepOrTarget === "number" ? { step: stepOrTarget } : stepOrTarget;
|
||||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||||
toast.error(`${title}: ${msg}`, {
|
toast.error(`${title}: ${msg}`, {
|
||||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
duration: 8000,
|
||||||
|
action: {
|
||||||
|
label: "رفع خطا",
|
||||||
|
onClick: () => {
|
||||||
|
goToStep(target.step);
|
||||||
|
setTimeout(() => {
|
||||||
|
const targetAttr = target.field ? `[data-field="${target.field}"]` : "";
|
||||||
|
const el = (targetAttr ? document.querySelector(targetAttr) : null) ||
|
||||||
|
document.querySelector(`[data-step="${target.step}"]`);
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
const input = el.querySelector("input, textarea, select") as HTMLElement | null;
|
||||||
|
if (input) input.focus();
|
||||||
|
else el.focus?.();
|
||||||
|
el.classList.add("ring-2", "ring-red-500", "rounded-xl");
|
||||||
|
setTimeout(() => el.classList.remove("ring-2", "ring-red-500", "rounded-xl"), 3500);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const guessStep = (msg: string): number => {
|
|
||||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
|
||||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
|
||||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
|
||||||
if (/پروژه|project/i.test(msg)) return 4;
|
|
||||||
return 5;
|
|
||||||
};
|
|
||||||
|
|
||||||
const name = supplierName.trim();
|
const name = supplierName.trim();
|
||||||
let supplierId = "";
|
let supplierId = "";
|
||||||
@@ -243,18 +288,26 @@ export function WizardPage() {
|
|||||||
const total = it.total;
|
const total = it.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
let material = materials.find((m) => 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(
|
material = materials.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
|
looksLikeUuid(m.id) &&
|
||||||
normalize(m.category) === normalize(cat) &&
|
normalize(m.category) === normalize(cat) &&
|
||||||
normalize(m.brand) === normalize(brand) &&
|
normalize(m.brand) === normalize(brand) &&
|
||||||
specsKey(m.specs) === specsKey(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),
|
||||||
|
);
|
||||||
|
}
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material && looksLikeUuid(material.id)) {
|
||||||
materialId = material.id;
|
materialId = material.id;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -265,7 +318,7 @@ export function WizardPage() {
|
|||||||
category: cat,
|
category: cat,
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit || "عدد",
|
||||||
});
|
});
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -354,8 +407,8 @@ export function WizardPage() {
|
|||||||
toast.success("پیشنهاد ذخیره شد.");
|
toast.success("پیشنهاد ذخیره شد.");
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
const target = resolveErrorTarget(e);
|
||||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
sectionError(target, "خطا در ثبت پیشنهاد", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -385,7 +438,7 @@ export function WizardPage() {
|
|||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -409,12 +462,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -425,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -439,7 +492,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
|
|||||||
Reference in New Issue
Block a user