9 Commits
Author SHA1 Message Date
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted.

- new quote_service.cleanup_orphan_materials(db, material_ids): for each candidate it checks usage across line_items (all quotes) and warehouse inventory; only when both are zero does it remove the auxiliary rows (supplier_materials, brand_materials, material_units, material_attribute_values) and finally the materials row; returns the purged ids
- replace_quote_children (edit modal): snapshots the quote's material ids before the children are replaced, diffs against the incoming items, and purges whatever was detached
- delete_quote: collects the quote's material ids + attachment keys first, deletes the quote, purges orphans inside the transaction, and only then (post-commit) removes the MinIO objects — now both the recorded attachment keys and a recursive remove_prefix(quote_id/) sweep, so stray objects under the quote folder cannot survive; the response's cascaded block reports purged_orphan_materials
- line_items: PATCH that switches a line item's material, and DELETE of a line item, both clean up the previously referenced material
- storage.remove_prefix(prefix): best-effort recursive object deletion under a prefix (sync MinIO client wrapped in asyncio.to_thread, same pattern as remove_object)

Safety: usage checks run against the whole system before any delete, so shared materials — including those held only by warehouse stock — are never touched. Verified: python compile + api image build clean.
2026-09-17 18:04:57 +03:30
whqreza 43ff048899 fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
Wizard:
- failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing
- material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows

Accept flow:
- quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: [])

Dates/time:
- JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread

Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis.

Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
2026-09-17 16:13:27 +03:30
Behrad c395fba8dd fix(inventory): replace native select with touch chips in ChangeInventoryPage 2026-09-17 14:04:01 +03:30
Behrad 66216c2b2f fix(inventory): optimize mobile layout with card view and chip filters 2026-09-17 13:53:24 +03:30
Behrad 435e6cbb21 fix(procurement): improve mobile layout for QuoteCard and EditModal 2026-09-17 13:19:14 +03:30
MMD 7b3ae3cb60 feat(wizard): cache مشخصه globally instead of per topic/category/brand
The specs combobox drew only from materials in the selected topic, further
narrowed by the row's category and brand, so a spec was offered only when
some material in that topic shared the same category AND brand. On a new
quote the user therefore lost specs recorded in earlier quotes.

- api.specsList(): new global source reading the attribute-definitions
  registry (topic/category/brand independent)
- materialOptions('spec'): unions the registry, specs of all materials,
  and specs typed in sibling rows — no topic/category/brand filter
- EditModal: same global registry added to its spec options

Verified the registry endpoint returns labels and that the union includes
specs absent from material attributes (e.g. 'مسی').
2026-09-16 20:31:01 +03:30
MMD ae977e5e66 fix(wizard): store material name cleanly instead of brand+category+specs
Adding a new item wrote the material name as 'brand + category + specs'
(e.g. 'پارس لوله لوله PE100 سایز ۱۶'), while specs were also stored
correctly in EAV attributes. The wizard then tried to recover the base
name by stripping brand and specs out of that string, but never stripped
the injected category — so the spec stayed baked into the cached material
name and the مشخصه appeared as part of the material name on later visits.

- api.ts / WizardPage / EditModal: build name from the material itself
  only (brand goes to brand_ids, specs to attributes)
- materialBaseName: strip category too, never strip a part that would
  empty the name, and reject a spec-only result; legacy rows whose name
  cannot be disentangled fall back to the stored name rather than a guess
- WizardPage: match existing materials on category+brand so legacy rows
  are still reused instead of duplicated
2026-09-16 20:24:45 +03:30
MMD b65938ef06 Merge prod_v3 (quote locking, per-item target project, title dedup) into production
Brings in prod_v3 5084623 on top of production 9e8a236. Merged without
conflicts: prod_v3's base (7ead60c) is an ancestor of production, and
production's extra tab-bar refinements (17c7ae6) touch disjoint regions.
2026-09-16 19:43:25 +03:30
whqreza 5084623be5 feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings

Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item

Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals

Verified: python compile + api/frontend image builds (tsc + vite) clean.
2026-09-16 19:14:06 +03:30
28 changed files with 1842 additions and 789 deletions
+1
View File
@@ -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
+13
View File
@@ -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}}
+13 -2
View File
@@ -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:
+153 -31
View File
@@ -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),
},
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+19
View File
@@ -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)
+241 -40
View File
@@ -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}>
امروز امروز
+9 -4
View File
@@ -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>
); );
} }
+29 -17
View File
@@ -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">
+4 -1
View File
@@ -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}
+1 -1
View File
@@ -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
View File
@@ -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);
+25 -9
View File
@@ -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();
} }
+77 -17
View File
@@ -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 || "—";
} }
+3 -1
View File
@@ -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)
+13 -20
View File
@@ -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>
+2 -4
View File
@@ -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)}
+459 -289
View File
@@ -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>
</>
); );
} }
+74 -25
View File
@@ -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">
+2 -6
View File
@@ -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 -3
View File
@@ -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;
} }
+290 -87
View File
@@ -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
View File
@@ -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}
+1 -1
View File
@@ -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>
پروژه‌ها پروژه‌ها
+4 -4
View File
@@ -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 ? (
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */ /* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
+84 -31
View File
@@ -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}