9 Commits
Author SHA1 Message Date
whqreza 65758b5e78 fix(projects): isolate quote acceptance status per project (decision-target based)
Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link.

Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification.

Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column.

Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items.

Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean.
2026-09-19 15:11:57 +03:30
whqreza b1edf6eed3 feat(inventory)!: lock warehouses permanently to their parent project
Warehouses now belong strictly and exclusively to the project they were created under; cross-project reassignment is removed end-to-end.

Backend: PATCH /inventory/warehouses/{id} no longer applies project_id — any attempt to move a warehouse to a different project returns 400 with «پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد» (sending the current project stays a harmless no-op). The WarehouseUpdate schema documents project_id as accept-only-to-reject; creation still requires project_id and delete is unchanged.

Frontend: the Organization page's per-warehouse project dropdown («انتقال به پروژه دیگر») and its moveWarehouse handler are removed — warehouses render statically inside their project card with only the dashboard link and delete; the warehouses.update API type no longer accepts project_id. WarehousesPage already had no move UI (rename/describe only).

Verified end-to-end against the running stack: rename 200; same-project project_id no-op 200; cross-project reassignment 400 with the guard message; warehouse remains in its original project; test data cleaned up. python compile + api/frontend builds clean.
2026-09-19 14:32:46 +03:30
whqreza a181a8753d feat(quotes,units): official-invoice badge, conversion text wrapping, seeded fixed conversions, universal proforma, EditModal fixes
1) Quote card header: an official/unofficial badge (رسمی / غیررسمی) renders next to the supplier name from quotes.is_official (the «فاکتور رسمی» checkbox) — green for official, muted slate for unofficial.

2) Conversion Factors sidebar: the material group and variant labels no longer truncate with ellipsis — whitespace-normal + break-words so long Persian names wrap cleanly to the next line.

3) Global fixed conversions are now seeded in the unified seeder (app/db/init_db.py — the same file that seeds categories, base units and the superuser path), NOT hardcoded in the frontend: تن→کیلوگرم (1000), کیلوگرم→گرم (1000), هزار عدد→عدد (1000). The supporting units (گرم, هزار عدد) were added to the idempotent BASE_UNITS seed; inserts use ON CONFLICT (from_unit, to_unit) DO NOTHING against the uq_standard_conversion key so every startup is safe. Verified live: 3 rows after first boot AND still 3 rows after an api restart.

4) Universal proforma attachments: the Edit modal now has a پیش‌فاکتورها section (list/upload/delete via the existing MinIO-backed files API) alongside the invoices section, active at every quote lifecycle stage — including accepted/arrived and receipts-locked quotes (the section is deliberately not gated by hasReceipts; the backend already permits uploads in any status). View/delete of existing proformas unchanged elsewhere.

5) EditModal fixes: (a) the supplier Combobox no longer opens on mount — Radix Dialog autofocus was triggering its onFocus; Combobox gains an openOnFocus prop (default true) and the modal passes false, so the dropdown now opens only on explicit click or typing; (b) the line-item material field binds to the material's own name (m.name) instead of its category relation, and the save path matches materials by name+brand+specs accordingly.

Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
2026-09-19 13:44:09 +03:30
whqreza 8adfd2267f refactor(catalog)!: decouple materials from categories (v2.7)
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.

Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup

Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned

Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30
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
40 changed files with 1766 additions and 972 deletions
+26 -6
View File
@@ -345,6 +345,18 @@ BASE_UNITS: list[dict] = [
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
]
# تبدیل‌های ثابت جهانی («تبدیل‌های ثابت — همه متریال‌ها») — ضرایب استاندارد و
# فراگیر که در هر استارت‌آپ به‌صورت idempotent ذخیره می‌شوند. کلید یکتا:
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
# قالبی که فرانت‌اند در standard_conversions می‌نویسد.
STANDARD_CONVERSIONS: list[dict] = [
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
]
@@ -379,16 +391,24 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
u,
)
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
# Seed the global fixed conversions (idempotent — unique key from/to unit).
for c in STANDARD_CONVERSIONS:
await run_statement(
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
"VALUES (:from_unit, :to_unit, :factor) "
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
c,
)
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("""
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price
AFTER INSERT OR UPDATE OF status ON item_decisions
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
""")
+1
View File
@@ -119,6 +119,7 @@ def _fa_validation_msg(msg: str) -> str:
@app.exception_handler(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]
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
field = ".".join(loc) if loc else None
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base):
__tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column(
@@ -149,7 +146,6 @@ class Material(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
+6 -5
View File
@@ -271,12 +271,13 @@ async def update_warehouse(
wh.name = payload.name
if payload.description is not None:
wh.description = payload.description
# پروژه‌ی انبار تغییرناپذیر است — انبار برای همیشه به پروژه‌ی والدش تعلق
# دارد و فقط با حذف و ساخت دوباره می‌تواند جابه‌جا شود.
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id")
wh.project_id = payload.project_id
raise ValidationError(
"پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
field="project_id",
)
await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
+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
await ensure_brand_material_belong(db, effective_bm, new_material_id)
old_material_id = li.material_id
if payload.material_id is not None:
li.material_id = payload.material_id
# 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:
li.notes = payload.notes
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]}
@@ -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))
if decision is not None and decision.status == "chosen":
raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست")
old_material_id = li.material_id
await db.delete(li) # specs / links / decision / receipt cascade via DDL
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}}
+11 -2
View File
@@ -18,7 +18,7 @@ from app.models.catalog import (
MaterialUnit,
Unit,
)
from app.models.procurement import LineItem
from app.models.procurement import LineItem, Supplier, SupplierMaterial
from app.schemas.catalog import (
MaterialAttributeValuesPut,
MaterialCreate,
@@ -108,7 +108,16 @@ async def list_materials(
):
stmt = select(Material)
if category_id:
stmt = stmt.where(Material.category_id == category_id)
sub = (
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == Material.id,
Supplier.category_id == category_id,
)
)
stmt = stmt.where(exists(sub))
if brand_id:
# WHERE EXISTS brand_materials with that brand_id
sub = select(1).where(
+5 -11
View File
@@ -195,17 +195,11 @@ async def pm_on_the_way(
# unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
mat_ids = {d.Material.id for d in decisions}
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
cats = {}
if mat_ids:
rows = (
await db.execute(
select(Material.id, MaterialCategory)
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
.where(Material.id.in_(mat_ids))
)
).all()
cats = {m_id: c for m_id, c in rows}
if sup_cat_ids:
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
cats = {c.id: c for c in rows}
out = []
for d, li, q, m, sup, proj in decisions:
@@ -220,7 +214,7 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name,
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
+29 -5
View File
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
from app.models.procurement import (
ItemDecision,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteProjects,
Supplier,
Termsheet,
)
from app.schemas.procurement import (
AcceptQuoteIn,
ArriveItemsIn,
@@ -39,9 +48,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
@@ -112,7 +120,23 @@ async def list_quotes(
if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_id)
if project_id:
stmt = stmt.where(Quote.project_id == project_id)
# any relevant link: legacy mirror, quote-level junction, line-item
# candidates, or an accepted (decision) target — NOT just the mirror
stmt = stmt.where(
Quote.id.in_(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
)
if category_id:
stmt = stmt.where(_quote_category_exists(category_id))
if quote_date_from is not None:
+2 -7
View File
@@ -58,11 +58,7 @@ async def list_supplier_materials(
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
cat_ids = {m.category_id for m in materials.values() if m.category_id}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
@@ -72,8 +68,7 @@ async def create_supplier_material(
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
return {"data": link_to_dict(link, material, category)}
return {"data": link_to_dict(link, material, None)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int
+7
View File
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
class WarehouseUpdate(APIModel):
"""Rename/re-describe a warehouse.
`project_id` is accepted only to be rejected: a warehouse belongs
permanently to its parent project and cannot be reassigned (the router
answers 400 unless the value equals the current project).
"""
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
project_id: UUID | None = None
+1 -1
View File
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id)."""
"""Link a global material to a supplier."""
material_id: UUID
+3 -11
View File
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
if "category" in expand:
cat_ids = {m.category_id for m in materials}
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
maps["categories"] = {c.id: c for c in rows}
unit_ids: set[int] = set()
if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials}
@@ -258,9 +253,9 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
"updated_at": to_iso(m.updated_at),
}
if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id))
d["category"] = None
else:
d["category_id"] = str(m.category_id)
d["category_id"] = None
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
@@ -326,9 +321,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials}
cat_ids = {m.category_id for m in materials}
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
data.categories = {c.id: c for c in cats}
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set()
for bm in bms:
@@ -450,7 +442,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.categories.get(m.category_id)),
"category": None,
"brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, [])
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
@@ -110,9 +109,7 @@ async def validate_attributes(
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
"""
async with db.begin():
duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material(
category_id=payload.category_id,
name=payload.name,
topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id,
+212 -50
View File
@@ -290,12 +290,78 @@ async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
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(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered and has no warehouse receipts.
Automatically purges any materials detached during this replacement if they become orphans.
"""
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
@@ -309,6 +375,11 @@ async def replace_quote_children(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
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
await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
@@ -317,10 +388,21 @@ async def replace_quote_children(
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 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)
return quote
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
@@ -346,7 +428,7 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
await db.execute(
update(ItemDecision)
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
.where(ItemDecision.line_item_id.in_(li_ids))
.values(
status="pending",
decided_at=None,
@@ -524,21 +606,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
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))
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
# termsheets referenced by at least one chosen item -> accepted
if chosen_ts_ids:
@@ -735,30 +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()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
# clean up attachments in MinIO before cascade
# 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()
for att in attachments:
try:
await storage.remove_object(att.object_key)
except Exception:
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
att_keys = [att.object_key for att in attachments]
quote_prefix = f"{quote.id}/"
await db.delete(quote)
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),
},
}
await db.flush()
# Purge any materials that no longer have any references in the system
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
for key in att_keys:
try:
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),
},
}
# ---------------------------------------------------------------------------
@@ -816,37 +903,57 @@ async def quote_stats(
def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id."""
from app.models.catalog import Material
"""EXISTS: quote's supplier belongs to category_id."""
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for one project.
"""Counts + total value per quote status for ONE project, isolated per project.
total_value: SUM(quantity × unit_price) over line items using the chosen
termsheet link first, else the first link. Rejected line items are
excluded from value sums (contract P1-14).
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
least one non-rejected line item's decision targets this project
(item_decisions.project_id). A quote merely linked to the project as a
candidate (quote-level link, line-item candidates, or accepted for a
DIFFERENT project) stays in «offered» — this project never accepted it.
total_value: SUM(quantity × unit_price) over the bucket's line items,
using the chosen termsheet link first, else the first link. Rejected
line items are excluded from value sums (contract P1-14).
"""
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
# the route passes the raw path string — normalize for the Python-side
# UUID comparisons below (decision.project_id == project_id etc.)
project_id = UUID(str(project_id))
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
if not quotes:
# --- every quote RELEVANT to this project (any kind of link) ---
quote_id_rows = await db.execute(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
quote_ids = [row[0] for row in quote_id_rows.all()]
if not quote_ids:
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
return {"project_id": str(project_id), "total_quotes": 0, **empty}
quote_ids = [q.id for q in quotes]
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
li_ids = [li.id for li in line_items]
@@ -854,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} if li_ids else {}
# line items explicitly linked to this project as candidates
candidate_li_ids = set(
(
await db.execute(
select(LineItemProjects.line_item_id).where(
LineItemProjects.line_item_id.in_(li_ids),
LineItemProjects.project_id == project_id,
)
)
).scalars().all()
) if li_ids else set()
item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
@@ -862,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it)
def _items_of(quote: Quote) -> list[LineItem]:
return [li for li in line_items if li.quote_id == quote.id]
def _accepted_for_project(quote: Quote) -> bool:
"""≥1 non-rejected line item whose decision targets THIS project."""
return any(
(decision := decisions.get(li.id)) is not None
and decision.status == "chosen"
and decision.project_id == project_id
for li in _items_of(quote)
)
def _has_any_target(quote: Quote) -> bool:
"""Whether any decision of this quote carries a target project at all
(legacy quotes accepted before the decision-project era have none —
they keep the old global-status classification)."""
return any(
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
for li in _items_of(quote)
)
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes:
per_status[q.status]["count"] += 1
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
else:
per_status[q.status]["count"] += 1
for li in line_items:
quote = next(q for q in quotes if q.id == li.quote_id)
decision = decisions.get(li.id)
# --- which status bucket does this line item's value belong to? ---
accepted_here = (
decision is not None
and decision.status == "chosen"
and decision.project_id == project_id
)
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
if accepted_here:
bucket = quote.status
elif li.id in candidate_li_ids:
# the quote is still just a proposal for this project — the
# item's candidate value stays in the offered bucket
bucket = "offered"
else:
continue # this item targets another project entirely
else:
bucket = quote.status
if decision is not None and decision.status == "rejected":
continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None:
@@ -877,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
price_it = links[0] if links else None
if price_it is None:
continue
quote = next(q for q in quotes if q.id == li.quote_id)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items():
+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)
except Exception:
logger.warning("Could not remove MinIO object %r — ignoring", key)
async def remove_prefix(prefix: str) -> None:
"""Delete all objects matching a prefix in the bucket (best-effort)."""
client = get_client()
def _sync_remove_prefix():
try:
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
for obj in objects:
try:
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
except Exception:
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
except Exception:
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
await asyncio.to_thread(_sync_remove_prefix)
@@ -1,10 +1,6 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
Linking a material to a supplier derives materials.category_id from the
supplier's category when the material has none (first-link-wins). The same
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
this service applies it in the application layer so the ORM state is
consistent inside the same transaction.
Suppliers can supply any global material across categories.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
"""Upsert a supplier<->material link (idempotent).
Caller is responsible for the surrounding transaction.
"""
@@ -42,15 +38,11 @@ async def link_supplier_material(
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link)
await db.flush()
# First-link-wins: only when the material has no category yet.
if material.category_id is None:
material.category_id = supplier.category_id
return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
"""Remove the link."""
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
+3 -3
View File
@@ -261,9 +261,7 @@ async def run() -> None:
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
category_id=cat.id,
name=name,
canonical_unit_id=unit_ids[unit_code],
status="active",
@@ -289,8 +287,10 @@ async def run() -> None:
await db.flush()
suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
for sup in suppliers:
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
target_slug = sup_cat_slug.get(sup.name)
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
for m in cat_materials[:2]:
db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True,
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
}
function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => {
setText(e.target.value);
setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value);
}}
onFocus={() => setOpen(true)}
onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
+185 -37
View File
@@ -6,6 +6,7 @@ import {
jalaliStrToIso,
jalaliToGregorian,
MONTHS,
toAsciiDigits,
toFa,
WEEKDAYS,
} from "../lib/jalali";
@@ -29,7 +30,7 @@ function todayJalali(): [number, number, number] {
function isoToJalali(iso: string | null): [number, number, number] | null {
if (!iso) return null;
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
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);
@@ -78,6 +79,10 @@ export function JalaliDatePicker({
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
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>(() => {
if (!value) return "";
const j = isoToJalaliStr(value);
@@ -92,19 +97,28 @@ export function JalaliDatePicker({
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 d = new Date(value);
if (Number.isNaN(d.getTime())) {
setText(j);
} else {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
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);
@@ -116,14 +130,13 @@ export function JalaliDatePicker({
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم
function selectedLocalDate(): string | null {
if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
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);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const selectedIso = selectedLocalDate();
const time = timeOf(value);
const grid = useMemo(() => {
const [jy, jm] = curJ;
@@ -163,36 +176,108 @@ export function JalaliDatePicker({
};
const selectDay = (jy: number, jm: number, jd: number) => {
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
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(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
`${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);
};
const setTimePart = (part: "h" | "m", raw: string) => {
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
if (Number.isNaN(n)) return;
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
const handleHourChange = (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) {
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;
onChange(buildIso(jy, jm, jd, h, m));
// به‌روزرسانی نمایش
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
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 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 now = new Date();
const [jy, jm, jd] = todayJalali();
const nh = now.getHours();
const nm = now.getMinutes();
setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
? buildIso(jy, jm, jd, nh, nm)
: buildDateOnly(jy, jm, jd);
onChange(val);
setText(
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"))}`,
);
setOpen(false);
@@ -201,17 +286,24 @@ export function JalaliDatePicker({
const clear = () => {
onChange(null);
setText("");
const now = nowTime();
setHourText(faNum(String(now.h).padStart(2, "0")));
setMinuteText(faNum(String(now.m).padStart(2, "0")));
setOpen(false);
};
const onInputChange = (v: string) => {
setText(v);
if (!v.trim()) {
onChange(null);
return;
}
const iso = jalaliStrToIso(v);
if (iso) {
if (!withTime) {
const nums = String(v)
.split(/[/\-.]/)
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
.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")}`);
@@ -226,6 +318,51 @@ export function JalaliDatePicker({
}
};
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 (
<div ref={ref} className={cn("relative", className)}>
<input
@@ -238,6 +375,7 @@ export function JalaliDatePicker({
onFocus={() => !disabled && setOpen(true)}
onClick={() => !disabled && setOpen(true)}
onChange={(e) => onInputChange(e.target.value)}
onBlur={handleInputBlur}
/>
{open && !disabled && (
<div
@@ -248,6 +386,7 @@ export function JalaliDatePicker({
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">
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
«
@@ -265,6 +404,8 @@ export function JalaliDatePicker({
»
</button>
</div>
{/* 2. Day of Week Header & Days Grid */}
<div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
@@ -291,33 +432,40 @@ export function JalaliDatePicker({
),
)}
</div>
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
{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">
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
<input
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"
value={faNum(String(time.h).padStart(2, "0"))}
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={minuteText}
inputMode="numeric"
maxLength={2}
onChange={(e) => setTimePart("h", e.target.value)}
onFocus={(e) => e.target.select()}
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>
</div>
<span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5">
<input
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"
value={faNum(String(time.m).padStart(2, "0"))}
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={hourText}
inputMode="numeric"
maxLength={2}
onChange={(e) => setTimePart("m", e.target.value)}
onFocus={(e) => e.target.select()}
onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur}
/>
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
</div>
</div>
)}
{/* 4. Action Buttons Footer */}
<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}>
امروز
+1 -1
View File
@@ -17,7 +17,7 @@ export function useAddMaterial() {
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (m: Material) => api.materials.create(m),
onMutate: (m) => addMaterial(m),
onSuccess: (created) => addMaterial(created),
onSettled: () => invalidate(),
});
}
+121 -38
View File
@@ -14,6 +14,7 @@
import { normalize } from "./normalize";
import { buildTopics } from "./topics";
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth";
import type {
Attachment,
@@ -33,7 +34,7 @@ import type {
Warehouse,
} 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;
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
expiry_date: "تاریخ انقضا",
accepted_date: "تاریخ پذیرش",
arrived_date: "تاریخ رسید",
due_date: "تاریخ سررسید",
schedule_steps: "مراحل پرداخت",
milestones: "مراحل پرداخت",
amount: "مبلغ",
step_no: "شماره مرحله",
};
const faField = (field: string): string => {
const last = field.split(".").filter(Boolean).pop() || field;
export const faField = (field: string): string => {
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
const last = parts.pop() || field;
return FIELD_FA[last] || FIELD_FA[field] || field;
};
// ترجمه‌ی خطای HTTP بر اساس کد وضعیت
const faHttpError = (status: number, fallback: string): string => {
// ترجمه‌ی خطای HTTP بر اساس کد وضعیت — پیام‌های معنادار بک‌اند حفظ می‌شوند
export const faHttpError = (status: number, fallback: string): string => {
if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است";
if (status === 404) return "مورد درخواستی یافت نشد";
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
if (status === 400) return "داده‌ی ارسال‌شده نامعتبر است";
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "داده‌ی ارسال‌شده نامعتبر است";
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": ...}
// ============================================================
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
headers: authHeaders(init),
});
} catch {
throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟");
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
if (!res.ok) {
let msg = `خطای سرور (${res.status})`;
let errCode: string | undefined;
let errField: string | undefined;
try {
const body = await res.json();
if (body?.error?.message) {
msg = body.error.message;
if (body?.error) {
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)) {
// FastAPI 422 — آرایه‌ی خطاهای اعتبارسنجی با پیام انگلیسی
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)) : "داده‌ی ارسال‌شده نامعتبر است";
msg = field ? `داده‌ی «${faField(field)}» نامعتبر است: ${fa}` : fa;
const fName = fieldName ? faField(fieldName) : "";
msg = fName ? `داده‌ی «${fName}» نامعتبر است: ${fa}` : fa;
} else if (body?.detail) {
msg = String(body.detail);
}
} catch {
/* بدنۀ خطا 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;
}
@@ -668,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
function toDateOnly(v: string | null | undefined): string | null {
if (!v) return null;
const str = String(v).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب می‌افتد)
const str = toAsciiDigits(v).trim();
if (!str) return null;
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سال‌های ۱۳۰۰ تا ۱۵۰۰)
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 (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")}`;
}
function toDateTime(v: string | null | undefined): string | null {
const d = toDateOnly(v);
if (!d) return null;
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
if (!v) return null;
const str = toAsciiDigits(v).trim();
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 {
return new Date().toISOString().slice(0, 10);
}
function looksLikeUuid(v: string): 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);
export function looksLikeUuid(v: string | null | undefined): boolean {
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());
}
// ============================================================
@@ -777,6 +857,13 @@ export const api = {
// مقدار پیش‌فرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
// پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند)
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);
for (const p of q.line_items[i].projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
@@ -870,7 +957,6 @@ export const api = {
if (pid) targetProjByLine.set(li.id, pid);
}
}
const allProjectIds = [...new Set([...targetProjByLine.values()])];
const payload = {
accepted_termsheets: acceptedTs.map((t) => ({
@@ -899,11 +985,7 @@ export const api = {
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند
};
await patchData(`/quotes/${id}/accept`, payload);
// پروژه‌های سطح پیشنهاد با اتحاد پروژه‌های اقلام همگام می‌شوند
if (allProjectIds.length) {
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
}
return { ...current, ...patch } as Quote;
return { ...current, ...patch, projects: current.projects } as Quote;
}
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
@@ -1026,12 +1108,6 @@ export const api = {
},
create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup();
// v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت
// نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً
// از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins).
// دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛
// در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم.
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// (بک‌اند canonical_unit_id را الزامی می‌داند)
const unitName = (m.base_unit || "").trim();
@@ -1056,7 +1132,6 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
}
const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId ?? null,
name,
topic: m.topic || null,
canonical_unit_id: unitId,
@@ -1259,6 +1334,10 @@ async function fetchProjects(): Promise<RawProject[]> {
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
if (!name) return null;
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);
if (pid) return pid;
const raw = await postData<RawProject>("/projects", { name });
@@ -1390,8 +1469,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بک‌اند)
export function nextId(prefix: string, items: { id: string }[]): string {
const regex = new RegExp(`^${prefix}(\\d+)$`);
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))
.reduce((a, b) => Math.max(a, b), 0);
return prefix + (max + 1);
@@ -1592,7 +1675,8 @@ export const apiV26 = {
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
return rawWarehouse(d);
},
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
// project_id عمداً در تایپ نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
return rawWarehouse(d);
},
@@ -1670,7 +1754,6 @@ export const apiV26 = {
}): Promise<{ id: string; name: string }> =>
postData("/materials", {
name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null,
canonical_unit_id: opts.canonical_unit_id,
status: "active",
+25 -9
View File
@@ -99,8 +99,11 @@ export const MONTHS = [
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
function toEnSafe(s: string): string {
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
export function toAsciiDigits(s: string | number | null | undefined): string {
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 "۱۴۰۳/۰۵/۱۲"
@@ -121,16 +124,29 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
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 {
if (!str) return null;
const nums = String(str)
.split(/[/\-.]/)
.map((x) => parseInt(toEnSafe(x), 10));
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
const [jy, jm, jd] = nums;
const s = toAsciiDigits(str).trim();
const m = s.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
if (!m) {
const nums = s
.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 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();
}
+30 -32
View File
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
}
// specها با جداکننده‌ی « · »
// specها با جداکننده‌ی « . »
export function specsLabel(specs: string[]): string {
return specs.join(" · ");
return specs.join(" . ");
}
/**
* عنوان تمیز و بدون تکرار کالا:
* ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک.
* ترتیب اکید با جداکننده‌ی نقطه:
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
*/
export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
extraSpecs?: string[],
): string {
if (!m) return "کالا";
const rawName = (m.name || "").trim();
let 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 : "کالا";
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود
if (normBrand && rawName) {
const normName = normalize(rawName);
if (normName.startsWith(normBrand)) {
const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
}
}
let title = rawName;
if (normBrand && !normName.includes(normBrand)) {
title = `${rawBrand} ${title}`;
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
const parts: string[] = [];
if (rawBrand) {
parts.push(rawBrand);
}
if (rawName) {
parts.push(rawName);
} else if (!rawBrand) {
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
}
const normTitle = normalize(title);
const distinctSpecs: string[] = [];
// اضافه کردن مشخصه‌ها بدون تکرار
const normCurrent = normalize(parts.join(" "));
const seenNormSpecs = new Set<string>();
for (const s of rawSpecs) {
@@ -79,15 +87,12 @@ export function formatMaterialTitle(
if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم
if (normTitle.includes(ns)) continue;
distinctSpecs.push(s);
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم
if (normCurrent.includes(ns)) continue;
parts.push(s);
}
if (distinctSpecs.length > 0) {
return `${title} · ${distinctSpecs.join(" · ")}`;
}
return title;
return parts.join(" . ");
}
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
@@ -147,17 +152,10 @@ export function lineItemTotal(li: LineItem): number | null {
// - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id)
export function itemProjects(q: Quote, li: LineItem): string[] {
if (q.status === "accepted" || q.status === "arrived") {
if (li.target_project?.name) {
return [li.target_project.name];
}
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تک‌پروژه بودن
if (li.projects && li.projects.length === 1) {
const p = li.projects[0];
return [typeof p === "string" ? p : p.name];
}
return [];
return li.target_project?.name ? [li.target_project.name] : [];
}
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
// For 'offered', 'rejected', 'expired': return all candidate projects
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
}
// ---------- واحد مرجع و ضریب تبدیل ----------
+2 -2
View File
@@ -20,9 +20,9 @@ export interface Supplier {
// ماده/کالا
export interface Material {
id: string; // "m1", "m2", ...
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + دسته + مشخصه)
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + مشخصه)
topic: string;
category: string; // مثلاً "میلگرد"
category: string; // اختیاری در بک‌اند؛ همیشه string در فرانت (default: "")
brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه
+2 -8
View File
@@ -219,20 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
// پروژه‌ی هر قلم (تکی هدف خرید) + همگام‌سازی پروژه‌های سطح پیشنهاد
const projUnion = new Set<string>();
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
next.line_items.forEach((li) => {
if (li.rejected) return;
const name = itemProjectsSel[li.id];
if (name === undefined) return;
li.target_project = name ? { id: "", name } : null;
// توجه: li.projects دست‌نخورده باقی می‌ماند تا پروژه‌های کاندیدای اولیه حفظ شوند
if (name) projUnion.add(name);
});
if (projUnion.size > 0) {
next.projects = [...projUnion];
next.project = next.projects[0] || "";
}
const now = new Date().toISOString();
next.accepted_date = acceptDate || now;
+423 -351
View File
@@ -37,6 +37,7 @@ import {
interface EditItemRow {
key: string;
origLi?: LineItem;
projects?: { id: string; name: string }[];
cat: string;
brand: string;
specs: string[];
@@ -61,7 +62,7 @@ export interface EditModalProps {
onClose: () => void;
}
// مودال ویرایش کامل پیشنهاد
// مودال ویرایش کامل پیشنهاد — بهینه‌شده برای صفحات موبایل
export function EditModal({ quoteId, onClose }: EditModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
@@ -87,6 +88,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -112,7 +114,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
}
};
// گزینه‌های مشخصه — همه‌ی مشخصه‌های ثبت‌شده (کش) + رجیستری سراسری
// گزینه‌های مشخصه
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => {
let alive = true;
@@ -120,7 +122,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.specsList()
.then((list) => alive && setGlobalSpecs(list))
.catch(() => {});
return () => { alive = false; };
return () => {
alive = false;
};
}, []);
const specOptions = useMemo(() => {
@@ -131,7 +135,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials, itemRows, globalSpecs]);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های خود پیشنهاد
// گزینه‌های پروژه
const projectOptions = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name));
@@ -146,7 +150,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!q) return;
const sup = supplierById(suppliers, q.supplier_id);
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 || "");
setItemRows(
q.line_items.map((li) => {
@@ -154,7 +168,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return {
key: li.id,
origLi: li,
cat: m.category || "",
projects: li.projects && li.projects.length ? [...li.projects] : [],
cat: m.name || "", // نام خود متریال — نه دسته/برند
brand: m.brand || "",
specs: materialSpecs(m),
qty: li.quantity,
@@ -183,7 +198,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
setReady(true);
}, [q, suppliers, materials]);
// فاکتورها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
// بارگذاری فاکتورها و پیش‌فاکتورها
useEffect(() => {
if (!quoteId) return;
let alive = true;
@@ -191,6 +206,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => {
alive = false;
};
@@ -250,7 +269,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده — موجود یا جدید
// تأمین‌کننده
if (supplierName.trim()) {
const norm = normalize(supplierName);
const exist = suppliers.find((s) => normalize(s.name) === norm);
@@ -259,7 +278,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} else {
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
const created = await addSupplier.mutateAsync(newSup);
next.supplier_id = created.id; // UUID واقعی از بک‌اند
next.supplier_id = created.id;
}
}
@@ -268,7 +287,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.notes = notes.trim();
next.invoices = invoices;
// termsheetها — id موجود حفظ می‌شود، جدیدها ساخته می‌شوند
// termsheetها
const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = [];
tsCards.forEach((card, i) => {
@@ -296,11 +315,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const qty = row.qty;
const price = row.price;
const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
if (!cat && !brand && qty == null && price == null) continue;
let material = materialsCopy.find(
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.name) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
@@ -318,7 +337,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
base_unit: unit,
};
materialsCopy.push(nm);
// منتظر ساخت متریال می‌مانیم تا id واقعی (UUID) به قلم وصل شود
const created = await addMaterial.mutateAsync(nm);
materialId = created.id;
}
@@ -348,8 +366,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
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({
id: "li-" + (newItems.length + 1),
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
material_id: materialId,
quantity: qty,
unit,
@@ -359,14 +384,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
accepted_ts_id: acceptedTsId,
rejected,
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;
// ترم‌شیت بدون قلم بی‌معنی است — حذف
const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
@@ -383,7 +407,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.arrived_date = arrivedDate;
next.status = status as Quote["status"];
// پاک‌سازی خودکار در بازگشت وضعیت
if (next.status === "offered") {
next.accepted_date = null;
next.arrived_date = null;
@@ -417,363 +440,412 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return (
<>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
</DialogHeader>
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
<DialogHeader>
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
</DialogHeader>
{hasReceipts && (
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12.5px] font-medium text-amber">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div>
)}
{hasReceipts && (
<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">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div>
)}
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<label>تأمین‌کننده</label>
<Combobox
value={supplierName}
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="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 className="w-full min-w-0">
<Combobox
value={supplierName}
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)}
openOnFocus={false}
/>
</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-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="field">
<label>متریال</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 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 className="field">
<label>برند</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="field">
<label>مشخصه</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="مشخصه"
/>
</div>
<div className="field">
<label>واحد</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="field">
<label>تعداد</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="field">
<label>قیمت واحد</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="field">
<label>قیمت کل</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="flex items-end justify-end">
{!hasReceipts && (
<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>
{/* تخصیص termsheet */}
<div className="mt-2.5 border-t border-line/60 pt-2">
{row.rejected ? (
<label className="ts-cb-label e-restore">
<input
type="checkbox"
checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/>
بازگرداندن به معامله
</label>
) : (
<>
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, idx) => (
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
<input
type="checkbox"
disabled={hasReceipts}
className="me-1 accent-accent disabled:cursor-not-allowed"
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>
{/* تخصیص termsheet / بازگرداندن */}
<div className="mt-2.5 border-t border-line pt-2">
{row.rejected ? (
<label className="ts-cb-label e-restore">
<input
type="checkbox"
checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/>
بازگرداندن به معامله
</label>
) : (
<>
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, idx) => (
<label key={card.key} className={cn("chip cursor-pointer", hasReceipts && "cursor-not-allowed opacity-60")}>
<input
type="checkbox"
disabled={hasReceipts}
className="me-1 accent-accent disabled:cursor-not-allowed"
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>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
+ افزودن قلم
</Button>
)}
</div>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
+ افزودن قلم
</Button>
)}
</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">
{tsCards.map((card, idx) => (
<div key={card.key} className="variant-card">
<div className="variant-head">
<span className="variant-num">{tsLabel(card, idx)}</span>
{/* 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) })}
/>
))}
</div>
{!hasReceipts && (
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
×
</button>
<Button
variant="line"
size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</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) })}
/>
))}
</div>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
+ افزودن termsheet
</Button>
)}
</div>
{/* فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<FileList
files={invoices}
onChange={setInvoices}
upload={(f) => api.files.upload(q.id, "invoice", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
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-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
/>
</div>
{/* تاریخ‌ها و وضعیت */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[12px] font-bold text-muted">تاریخ‌ها و وضعیت:</div>
<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>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} 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={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</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) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</div>
{!hasReceipts && (
<Button
variant="line"
size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</Button>
</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>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
+ افزودن termsheet
</Button>
)}
</div>
{/* فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<FileList
files={invoices}
onChange={setInvoices}
upload={(f) => api.files.upload(q.id, "invoice", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
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="تاریخ" />
</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={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
{/* فوتر و دکمه‌های اقدام */}
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
{q.status === "offered" ? (
<Button
type="button"
variant="danger"
onClick={() => setShowDeleteConfirm(true)}
className="w-full gap-1.5 sm:w-auto"
>
{statusOptionsFor(q.status).map((st) => (
<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>
)}
<Trash2 className="h-4 w-4" />
<span>حذف پیشنهاد</span>
</Button>
) : (
<div />
)}
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
انصراف
</Button>
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
ذخیره تغییرات
</Button>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse">
{q.status === "offered" ? (
{/* مودال تأیید حذف */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6">
<DialogHeader>
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm text-muted">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</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={() => setShowDeleteConfirm(true)}
className="gap-1.5"
onClick={() => void handleDelete()}
disabled={deleting}
>
<Trash2 className="h-4 w-4" />
<span>حذف پیشنهاد</span>
{deleting ? "در حال حذف..." : "حذف نهایی"}
</Button>
) : (
<div />
)}
<div className="flex items-center gap-2">
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* مدال تأیید حذف پیشنهاد */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md">
<DialogHeader>
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm text-muted">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</div>
<DialogFooter className="flex items-center justify-end gap-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>
</>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
}
+58 -48
View File
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
itemsSummary,
materialById,
materialLabel,
projectCell,
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const pc = priceCell(total);
const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0 flex-1">
<Link
to={`/suppliers/${q.supplier_id}`}
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
>
{sup.name}
</Link>
<div className="flex min-w-0 items-center gap-1.5">
<Link
to={`/suppliers/${q.supplier_id}`}
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
>
{sup.name}
</Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<span
className={cn(
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
q.is_official
? "bg-green-bg text-green"
: "bg-slate-bg text-muted",
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span>
</div>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
@@ -176,29 +188,35 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</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">
<InfoCell label="اقلام">
{summary.count > 1 ? (
<div className="flex flex-col gap-0.5">
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<span key={li.id} className="truncate">
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
</span>
);
})}
{(q.line_items?.length || 0) > 3 && (
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
)}
</div>
</div>
) : (
<span className="truncate">{summary.text}</span>
)}
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */}
<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="پروژه" 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">
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? (
activeItems.map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<span key={li.id} className="break-words">
{materialLabel(m)}
</span>
);
})
) : (
<span>—</span>
)}
</div>
{unitWarns.map((w) => (
<Link
key={w.unit}
@@ -206,25 +224,16 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
<TriangleAlert className="h-2.5 w-2.5" />
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
واحد جدید: {w.unit}
</Link>
))}
</InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
</span>
</span>
</InfoCell>
<InfoCell label="مجموع مبلغ">
<InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? (
<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>
</div>
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
@@ -234,10 +243,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
)}
</InfoCell>
<InfoCell label="شرایط معامله">
{/* ۴. شرایط معامله */}
<InfoCell label="شرایط معامله" className="col-span-1">
{ts.title !== "—" ? (
<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}
</div>
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
@@ -248,12 +258,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell>
</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]">
<span className="inline-flex items-center gap-1.5 truncate">
<Package className="h-3 w-3 shrink-0" />
<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 className="inline-flex items-center gap-1.5 truncate">
@@ -262,7 +272,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)}
</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 className="inline-flex items-center gap-1.5">
@@ -14,11 +14,11 @@ import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import { cn } from "../../lib/utils";
type FormType = TransactionType | "TRANSFER";
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: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", 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)}`;
}
// تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش)
export function ChangeInventoryPage() {
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
@@ -60,15 +59,12 @@ export function ChangeInventoryPage() {
const [brandTxt, setBrandTxt] = useState("");
const [unitTxt, setUnitTxt] = useState("");
const [qty, setQty] = useState("");
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
// مشخصه‌های فنی — چیپ‌های چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false);
// prefill از URL (پرش از کنترل روزانه)
useEffect(() => {
const t = params.get("type");
const w = params.get("wh");
@@ -91,7 +87,6 @@ export function ChangeInventoryPage() {
const formRecords = useMemo(() => {
const txt = matTxt.trim();
// انتقال از انبار مبدأ استفاده می‌کند، بقیه از انبار انتخابی
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => {
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 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 = () => {
try {
const d = new Date(dateInput);
@@ -115,8 +108,6 @@ export function ChangeInventoryPage() {
return new Date().toISOString();
};
// هر چیپ یک مشخصه است؛ در specs انبار به‌صورت کلید با مقدار خالی ذخیره می‌شود
// تا رکوردهای انبارِ دارای مشخصه‌های متفاوت از هم جدا بمانند
const specsFromChips = () => {
const out: Record<string, string> = {};
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());
}, [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 foundByKey = new Map<string, number>();
data.transactions
@@ -170,7 +158,9 @@ export function ChangeInventoryPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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(
() =>
lossTxs
@@ -188,7 +178,6 @@ export function ChangeInventoryPage() {
try {
for (const op of ops) await postOne(op);
toast.success("ثبت شد ✓");
// اعلام به تب‌های دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه می‌کنند)
notifyDataChanged("inventory");
setRows([]);
setMatTxt("");
@@ -205,7 +194,6 @@ export function ChangeInventoryPage() {
}
};
// ---------- submit handlers ----------
const submitPurchase = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
@@ -216,10 +204,9 @@ export function ChangeInventoryPage() {
if (!brandName) return toast.error("نام برند را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
try {
// ساختِ به‌هنگام موجودیت‌های ناشناخته در کاتالوگ سراسری (بدون پیش‌نیاز تدارکات)
let uid = unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
@@ -264,7 +251,6 @@ export function ChangeInventoryPage() {
await postSeq(ops);
};
// ---------- شمارش موجودی: پیش‌نمایش اختلاف + تأیید قبل از ثبت ----------
interface CountConfirmRow {
material: string;
unit: string;
@@ -358,7 +344,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null;
const item = pendingLosses[i];
if (!item) return null;
if (n > item.remaining) return null; // سقف سمت کلاینت (بک‌اند هم رد می‌کند)
if (n > item.remaining) return null;
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() };
})
@@ -382,12 +368,10 @@ export function ChangeInventoryPage() {
const canSubmit = (): boolean => {
if (type === "PURCHASE") {
// نام‌های تایپ‌شده لازم نیست از قبل در فهرست باشند — در submit ساخته می‌شوند
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 === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
// پیدا شده: هر مقدار واردشده باید داخل مانده‌ی مفقودی همان ردیف باشد
if (type === "FOUND") {
return !!(
whId &&
@@ -402,7 +386,6 @@ export function ChangeInventoryPage() {
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
};
// sync rows when the record list changes
useEffect(() => {
if (type === "RETURN") {
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
@@ -447,54 +430,68 @@ export function ChangeInventoryPage() {
const isTransfer = type === "TRANSFER";
return (
<div className="min-h-screen">
<div className="min-h-screen overflow-x-hidden pb-20">
<Header
title="تغییر موجودی"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */}
<div className="rounded-xl border border-line bg-panel p-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 w-full shadow-2xs">
<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) => (
<button
key={f.t}
type="button"
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}
>
{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>
))}
</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 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 ? (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>انبار *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={whId}
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
>
<option value="">— انتخاب کنید —</option>
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
<div className="mb-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">انبار *</label>
<div className="flex flex-wrap gap-1.5">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
className={cn(
"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>
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field">
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
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" ? "نام متریال برداشت‌شده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
value={matTxt}
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="field">
<label>از (انبار مبدأ) *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={fromId}
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
>
<option value="">— انتخاب انبار مبدأ —</option>
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
<label className="text-xs font-semibold text-muted">از (انبار مبدأ) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
className={cn(
"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 className="field">
<label>به (انبار مقصد) *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={toId}
disabled={!fromId}
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
>
<option value="">— انتخاب انبار مقصد —</option>
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
<label className="text-xs font-semibold text-muted">به (انبار مقصد) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.filter((w) => w.id !== fromId).map((w) => (
<button
key={w.id}
type="button"
disabled={!fromId}
onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
className={cn(
"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 className="field md:col-span-2">
<label>متریال *</label>
<label className="text-xs font-semibold text-muted">متریال *</label>
<input
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="نام متریال را تایپ کنید — همه مدل‌ها نمایش داده می‌شوند…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
@@ -550,7 +565,7 @@ export function ChangeInventoryPage() {
{type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label>متریال *</label>
<label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox
value={matTxt}
items={data.lookup.materials.map((mm) => mm.name)}
@@ -567,7 +582,7 @@ export function ChangeInventoryPage() {
)}
</div>
<div className="field">
<label>برند *</label>
<label className="text-xs font-semibold text-muted">برند *</label>
<Combobox
value={brandTxt}
items={data.lookup.brands.map((b) => b.name)}
@@ -579,7 +594,7 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field">
<label>واحد *</label>
<label className="text-xs font-semibold text-muted">واحد *</label>
<Combobox
value={unitTxt}
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
@@ -591,17 +606,17 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field">
<label>تعداد *</label>
<label className="text-xs font-semibold text-muted">تعداد *</label>
<input
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="مثلاً ۲۰۰"
value={qty}
onChange={(e) => setQty(e.target.value)}
/>
</div>
<div className="field">
<label>تاریخ و زمان (شمسی)</label>
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
<JalaliDatePicker
value={dateInput}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
@@ -610,7 +625,7 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field md:col-span-2">
<label>مشخصه‌های فنی *</label>
<label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label>
<MultiSelectCombobox
values={specsChips}
onChange={setSpecsChips}
@@ -619,9 +634,9 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field md:col-span-2">
<label>یادداشت</label>
<label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
placeholder="شماره فاکتور، تأمین‌کننده، توضیحات…"
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -647,12 +662,10 @@ export function ChangeInventoryPage() {
/>
)}
{/* برگشت — برداشت‌های امروز */}
{type === "RETURN" && (
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
)}
{/* انتقال */}
{type === "TRANSFER" && (
<RowTable
isCount={false}
@@ -669,7 +682,6 @@ export function ChangeInventoryPage() {
/>
)}
{/* پیدا شده — مفقودی‌ها (با سقف: پیدا شده ≤ مانده‌ی مفقودی) */}
{type === "FOUND" && (
<FoundTable
rows={rows}
@@ -681,13 +693,13 @@ export function ChangeInventoryPage() {
)}
{/* دکمه ثبت */}
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
<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-[11.5px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
<button
type="button"
disabled={!canSubmit() || busy}
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 }}
>
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
@@ -695,10 +707,10 @@ export function ChangeInventoryPage() {
</div>
</div>
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
{/* مودال تأیید شمارش موجودی */}
{countConfirm && (
<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>
<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 }: {
isCount: boolean;
rows: RowDraft[];
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>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
@@ -792,22 +803,22 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
<tbody>
{records.map((r, i) => (
<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-1.5 pe-3">{r.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-1.5 pe-3">
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-2 pe-3">
<input
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 ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<td className="py-2">
<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="توضیح (اختیاری)"
value={rows[i]?.note || ""}
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>
</table>
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
</div>
);
}
@@ -831,10 +842,10 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
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 (!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 (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
@@ -848,22 +859,22 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
<tbody>
{txs.map((t, i) => (
<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-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 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-1.5 pe-3">
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
<td className="py-2 pe-3">
<input
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="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<td className="py-2">
<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="توضیح برگشت"
value={rows[i]?.note || ""}
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;
}) {
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 (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<table className="w-full text-[12.5px] min-w-[550px]">
<thead>
<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">یادداشت</th>
</tr>
@@ -906,28 +917,26 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
const over = entered > remaining;
return (
<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-1.5 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-1.5 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-1.5 pe-3">
<td className="py-2 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-2 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-2 pe-3">
<input
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="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && (
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
تعداد پیداشده نمی‌تواند بیشتر از مقدار مفقودی باشد.
</div>
<div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>
)}
</td>
<td className="py-1.5">
<td className="py-2">
<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="توضیح"
value={rows[i]?.note || ""}
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 {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
}
+290 -87
View File
@@ -1,9 +1,8 @@
import { useMemo, useState } from "react";
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 { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu";
@@ -14,7 +13,7 @@ import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
// رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی
// رنگ‌بندی معنایی تراکنش‌ها
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
@@ -148,24 +147,23 @@ export function InventoryPage() {
}
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
title="سامانه مدیریت انبار"
sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
actions={
/* تب‌های ناوبری فقط در دسکتاپ — دکمه بالایی به طور کامل حذف شد */
<div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
</div>
}
/>
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
{/* نوار فیلتر و جستجوی واکنش‌گرا */}
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<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 w-full shadow-2xs">
<div className="flex items-center gap-2 w-full">
<div className="relative flex-1 min-w-0">
<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"
placeholder="جستجوی نام متریال…"
@@ -187,8 +185,10 @@ export function InventoryPage() {
type="button"
onClick={() => setShowMobileFilters((s) => !s)}
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",
hasFilter ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel text-ink hover:bg-bg"
"inline-flex shrink-0 h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
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" />
@@ -199,7 +199,7 @@ export function InventoryPage() {
<button
type="button"
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"
)}
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
@@ -210,83 +210,186 @@ export function InventoryPage() {
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتب‌سازی"}
</span>
</button>
<Badge variant="outline" className="hidden sm:inline-flex h-9 items-center px-2.5 text-xs">
{faNum(groups.length)} قلم
</Badge>
</div>
{/* گزینه‌های فیلتر پیشرفته */}
<div
className={cn(
"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 && (
{/* فیلتر سریع انبارها (چیپ‌های افقی لمسی) */}
{visibleWarehouses.length > 1 && (
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto pb-1 pt-0.5 [scrollbar-width:none]">
<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({ category: "", warehouse: "", material: "", project: "" })}
onClick={() => setFilters((f) => ({ ...f, warehouse: "" }))}
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>
)}
<div className="text-[11px] text-muted ms-auto sm:hidden">
نمایش {faNum(groups.length)} قلم کالا
{visibleWarehouses.map((w) => (
<button
key={w.id}
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>
)}
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{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 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 ? (
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
) : (
@@ -349,14 +452,9 @@ export function InventoryPage() {
</table>
)}
</div>
{/* متن راهنما فقط در دسکتاپ نمایش داده می‌شود */}
<div className="hidden md:block text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div>
</PageLayout>
{/* دکمه شناور تغییر موجودی (FAB دایره‌ای آبی با علامت جمع) */}
{/* دکمه شناور تغییر موجودی */}
{isPM && (
<Link
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 }: {
g: Group;
isExp: boolean;
@@ -148,16 +148,8 @@ export function OrganizationPage() {
}
};
const moveWarehouse = async (id: string, projectId: string) => {
try {
await apiV26.warehouses.update(id, { project_id: projectId });
toast.success("انبار منتقل شد ✓");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
}
};
// انبار برای همیشه به پروژه‌ی والدش تعلق دارد — انتقال به پروژه‌ی دیگر وجود ندارد؛
// انبار فقط با حذف و ساخت دوباره زیر پروژه‌ی موردنظر جابه‌جا می‌شود.
const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
@@ -329,16 +321,6 @@ export function OrganizationPage() {
>
📊 داشبورد
</button>
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
value={w.project_id}
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
title="انتقال به پروژه دیگر"
>
{data.lookup.projects.map((p2) => (
<option key={p2.id} value={p2.id}>{p2.name}</option>
))}
</select>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div>
</div>
+50 -22
View File
@@ -16,7 +16,6 @@ import {
import { normalize } from "../../lib/normalize";
import { useAppStore } from "../../store";
import {
activeItems,
lineItemTotal,
materialById,
materialLabel,
@@ -29,25 +28,45 @@ import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button";
function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
return quotes.filter(
(q) =>
(q.projects || []).includes(name) ||
q.project === name ||
(q.line_items || []).some(
(li) =>
li.target_project?.name === name ||
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
),
);
}
// اقلام مربوط به این پروژه در یک quote
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده،
// برای این پروژه همچنان «پیشنهاد شده» است.
function acceptedForProject(q: Quote, name: string): boolean {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
}
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project);
}
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name);
if (targeted.length) return targeted;
if (items.some((li) => li.projects && li.projects.length)) {
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
}
return items;
}
function qTotal(q: Quote): number {
return activeItems(q).reduce((sum, li) => {
const t = lineItemTotal(li);
return sum + (t || 0);
}, 0);
}
function daysSince(iso: string | null): number | null {
if (!iso) return null;
const d = new Date(iso);
@@ -147,10 +166,11 @@ export function ProjectsPage() {
// ---------- پنل جزئیات یک پروژه ----------
function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name);
const sumBy = (st: Quote["status"]) =>
quotes
.filter((q) => q.status === st)
.reduce((s, q) => s + qTotal(q), 0);
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
const itemsTotal = (q: Quote) =>
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
const matName = (mid: string) => {
const m = materialById(store.materials, mid);
@@ -184,11 +204,19 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
);
};
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes
.filter((q) => q.status === "offered")
.filter(
(q) =>
q.status === "offered" ||
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
)
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
const accepted = quotes.filter((q) => q.status === "accepted");
const arrived = quotes.filter((q) => q.status === "arrived");
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
const offeredRow = (q: Quote) => {
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
@@ -199,7 +227,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
جمع: <b>{faNum(qTotal(q))} تومان</b>
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
</div>
<div className="pj-side">
اعتبار تا {fmtDate(q.expiry_date)}<br />
@@ -240,7 +268,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
هزینه: <b>{faNum(qTotal(q))} تومان</b>
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
</div>
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
</div>
@@ -255,13 +283,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
<div className="mb-3 flex flex-wrap gap-1.5">
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
ارزش باز: {faNum(sumBy("offered"))} تومان
ارزش باز: {faNum(sumBy(offered))} تومان
</span>
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
</span>
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
ارزش رسیده: {faNum(sumBy(arrived))} تومان
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
+2 -2
View File
@@ -496,7 +496,7 @@ export function UnitsPage() {
)}
/>
)}
<span className="truncate">{g.base}</span>
<span className="whitespace-normal break-words text-start">{g.base}</span>
</span>
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
</button>
@@ -519,7 +519,7 @@ export function UnitsPage() {
)}
onClick={() => setMaterialId(v.id)}
>
<span className="truncate">{v.label}</span>
<span className="whitespace-normal break-words text-start">{v.label}</span>
<span className="ut-ref">{v.refUnit || "—"}</span>
</button>
))}
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
))
)}
</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>
<Combobox
value={supplierName}
+6 -5
View File
@@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category
return base || fallback;
}
// مقادیر مجزای category/brand/spec داخل تاپیک
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
export function materialOptions(
materials: Material[],
topic: string,
_topic: string,
item: ItemDraft,
field: "cat" | "brand" | "spec",
allItems: ItemDraft[] = [],
apiBrands: string[] = [],
apiSpecs: string[] = [],
): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials;
const cat = item.cat.trim();
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
@@ -92,7 +93,7 @@ export function materialOptions(
};
if (field === "cat") {
const base = new Set(list.map((m) => m.category).filter(Boolean));
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
@@ -221,7 +222,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<span>قیمت کل</span>
</div>
<div className="flex flex-col gap-2">
<div data-field="line_items" className="flex flex-col gap-2">
{items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row items-center">
<Combobox
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
</h3>
<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) => (
<TermsheetCard
key={card.key}
+1 -1
View File
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
};
return (
<div>
<div data-field="project">
<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>
پروژه‌ها
+4 -4
View File
@@ -57,20 +57,20 @@ export function Step5Review({
<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>
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
<div className="hint">پیش‌فرض امروز</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>
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
</div>
<div className="field col-span-12">
<div className="field col-span-12" data-field="notes">
<label>یادداشت (اختیاری)</label>
<textarea
rows={2}
@@ -81,7 +81,7 @@ export function Step5Review({
/>
</div>
<div className="field col-span-12">
<div className="field col-span-12" data-field="proforma">
<label>پیش‌فاکتور (اختیاری)</label>
{proforma ? (
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
+77 -31
View File
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types";
import { api, nextId } from "../../lib/api";
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
import {
useAddMaterial,
useAddProject,
@@ -125,16 +125,8 @@ export function WizardPage() {
goToStep(currentStep + 1);
};
const rememberMaterial = (rec: MaterialRec) => {
void addMaterial.mutateAsync({
id: nextId("m", materials),
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
topic: rec.topic,
category: rec.category,
brand: rec.brand,
specs: rec.specs,
base_unit: "",
});
const rememberMaterial = (_rec: MaterialRec) => {
// رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
};
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ----------
@@ -193,19 +185,72 @@ export function WizardPage() {
// ---------- ذخیره ----------
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 : "خطای ناشناخته";
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();
let supplierId = "";
@@ -244,10 +289,11 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue;
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) {
material = materials.find(
(m) =>
looksLikeUuid(m.id) &&
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
@@ -257,11 +303,11 @@ export function WizardPage() {
// تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند.
if (!material && cat) {
material = materials.find(
(m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
);
}
let materialId: string;
if (material) {
if (material && looksLikeUuid(material.id)) {
materialId = material.id;
} else {
try {
@@ -272,7 +318,7 @@ export function WizardPage() {
category: cat,
brand,
specs,
base_unit: unit,
base_unit: unit || "عدد",
});
materialId = created.id;
} catch (err) {
@@ -361,8 +407,8 @@ export function WizardPage() {
toast.success("پیشنهاد ذخیره شد.");
navigate("/");
} catch (e) {
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
const target = resolveErrorTarget(e);
sectionError(target, "خطا در ثبت پیشنهاد", e);
}
};
@@ -392,7 +438,7 @@ export function WizardPage() {
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{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
selectedTopic={selectedTopic}
onTopicSelect={(t) => {
@@ -416,12 +462,12 @@ export function WizardPage() {
</div>
)}
{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} />
</div>
)}
{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
cards={cards}
onCardsChange={setCards}
@@ -432,7 +478,7 @@ export function WizardPage() {
</div>
)}
{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
items={items}
projects={projectNames}
@@ -446,7 +492,7 @@ export function WizardPage() {
</div>
)}
{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
quoteDate={quoteDate}
onQuoteDate={setQuoteDate}
+2 -22
View File
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
);
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
-- category_id is NULLABLE and DERIVED automatically from the first linked
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- Materials are global, category-free entities. name is GLOBALLY
-- unique (uq_material_name). topic is a legacy display column no longer
-- written by the app.
CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
topic TEXT, -- LEGACY display column
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
@@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
-- ============================================================================
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
-- Idempotent: only touches rows where category_id IS NULL.
CREATE OR REPLACE FUNCTION fn_derive_material_category()
RETURNS TRIGGER AS $$
BEGIN
UPDATE materials
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
updated_at = now()
WHERE id = NEW.material_id
AND category_id IS NULL;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
-- inserts decisions directly as 'chosen').