4 Commits
Author SHA1 Message Date
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
42 changed files with 1434 additions and 1479 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": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False}, {"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False}, {"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
]
# تبدیل‌های ثابت جهانی («تبدیل‌های ثابت — همه متریال‌ها») — ضرایب استاندارد و
# فراگیر که در هر استارت‌آپ به‌صورت idempotent ذخیره می‌شوند. کلید یکتا:
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
# قالبی که فرانت‌اند در standard_conversions می‌نویسد.
STANDARD_CONVERSIONS: list[dict] = [
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
] ]
@@ -379,16 +391,24 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING", "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
u, u,
) )
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync). # Seed the global fixed conversions (idempotent — unique key from/to unit).
for c in STANDARD_CONVERSIONS:
await run_statement(
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
"VALUES (:from_unit, :to_unit, :factor) "
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
c,
)
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement(""" await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
CREATE TRIGGER trg_supplier_materials_derive_category await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
AFTER INSERT ON supplier_materials await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price CREATE TRIGGER trg_item_decisions_sync_price
AFTER INSERT OR UPDATE OF status ON item_decisions AFTER INSERT OR UPDATE OF status ON item_decisions
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price() 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) @app.exception_handler(RequestValidationError)
async def validation_error_handler(_request: Request, exc: RequestValidationError): async def validation_error_handler(_request: Request, exc: RequestValidationError):
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
first = exc.errors()[0] first = exc.errors()[0]
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")] loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
field = ".".join(loc) if loc else None field = ".".join(loc) if loc else None
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived. # v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column( canonical_unit_id: Mapped[int] = mapped_column(
@@ -149,7 +146,6 @@ class Material(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
) )
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
+13
View File
@@ -108,6 +108,7 @@ async def update_line_item(
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
await ensure_brand_material_belong(db, effective_bm, new_material_id) await ensure_brand_material_belong(db, effective_bm, new_material_id)
old_material_id = li.material_id
if payload.material_id is not None: if payload.material_id is not None:
li.material_id = payload.material_id li.material_id = payload.material_id
# v2.5: keep the supplier<->material catalog edge in sync. # v2.5: keep the supplier<->material catalog edge in sync.
@@ -121,6 +122,11 @@ async def update_line_item(
if payload.notes is not None: if payload.notes is not None:
li.notes = payload.notes li.notes = payload.notes
await db.flush() await db.flush()
if payload.material_id is not None and payload.material_id != old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": (await serialize_line_items(db, [li], set()))[0]} return {"data": (await serialize_line_items(db, [li], set()))[0]}
@@ -133,6 +139,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id)) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is not None and decision.status == "chosen": if decision is not None and decision.status == "chosen":
raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست") raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست")
old_material_id = li.material_id
await db.delete(li) # specs / links / decision / receipt cascade via DDL await db.delete(li) # specs / links / decision / receipt cascade via DDL
await db.flush() await db.flush()
if old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": {"id": str(line_item_id), "deleted": True}} return {"data": {"id": str(line_item_id), "deleted": True}}
+11 -2
View File
@@ -18,7 +18,7 @@ from app.models.catalog import (
MaterialUnit, MaterialUnit,
Unit, Unit,
) )
from app.models.procurement import LineItem from app.models.procurement import LineItem, Supplier, SupplierMaterial
from app.schemas.catalog import ( from app.schemas.catalog import (
MaterialAttributeValuesPut, MaterialAttributeValuesPut,
MaterialCreate, MaterialCreate,
@@ -108,7 +108,16 @@ async def list_materials(
): ):
stmt = select(Material) stmt = select(Material)
if category_id: if category_id:
stmt = stmt.where(Material.category_id == category_id) sub = (
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == Material.id,
Supplier.category_id == category_id,
)
)
stmt = stmt.where(exists(sub))
if brand_id: if brand_id:
# WHERE EXISTS brand_materials with that brand_id # WHERE EXISTS brand_materials with that brand_id
sub = select(1).where( sub = select(1).where(
+5 -11
View File
@@ -195,17 +195,11 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
mat_ids = {d.Material.id for d in decisions} sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
cats = {} cats = {}
if mat_ids: if sup_cat_ids:
rows = ( rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
await db.execute( cats = {c.id: c for c in rows}
select(Material.id, MaterialCategory)
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
.where(Material.id.in_(mat_ids))
)
).all()
cats = {m_id: c for m_id, c in rows}
out = [] out = []
for d, li, q, m, sup, proj in decisions: for d, li, q, m, sup, proj in decisions:
@@ -220,7 +214,7 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name, "material": {"id": str(m.id), "name": m.name,
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None}, "category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
+2 -3
View File
@@ -39,9 +39,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
def _quote_category_exists(category_id) -> exists: def _quote_category_exists(category_id) -> exists:
return exists( return exists(
select(1).where( select(1).where(
LineItem.quote_id == Quote.id, Supplier.id == Quote.supplier_id,
Material.id == LineItem.material_id, Supplier.category_id == category_id,
Material.category_id == category_id,
) )
) )
+2 -7
View File
@@ -58,11 +58,7 @@ async def list_supplier_materials(
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items} mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
cat_ids = {m.category_id for m in materials.values() if m.category_id} return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
@@ -72,8 +68,7 @@ async def create_supplier_material(
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id) material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None return {"data": link_to_dict(link, material, None)}
return {"data": link_to_dict(link, material, category)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel): class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1) name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int canonical_unit_id: int
+1 -1
View File
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
class SupplierMaterialCreate(APIModel): class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id).""" """Link a global material to a supplier."""
material_id: UUID material_id: UUID
+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] material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}} maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
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() unit_ids: set[int] = set()
if "canonical_unit" in expand: if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials} 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), "updated_at": to_iso(m.updated_at),
} }
if "category" in expand: if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id)) d["category"] = None
else: else:
d["category_id"] = str(m.category_id) d["category_id"] = None
if "canonical_unit" in expand: if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id)) d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else: else:
@@ -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} material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all() materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials} data.materials = {m.id: m for m in materials}
cat_ids = {m.category_id for m in materials}
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
data.categories = {c.id: c for c in cats}
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all() bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set() brand_ids: set[uuid.UUID] = set()
for bm in bms: for bm in bms:
@@ -450,7 +442,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = { d["material"] = {
"id": str(m.id), "id": str(m.id),
"name": m.name, "name": m.name,
"category": category_brief(data.categories.get(m.category_id)), "category": None,
"brands": [ "brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code} {"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, []) for bm in data.material_brands.get(m.id, [])
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial, BrandMaterial,
Material, Material,
MaterialAttributeValue, MaterialAttributeValue,
MaterialCategory,
Unit, Unit,
) )
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
@@ -110,9 +109,7 @@ async def validate_attributes(
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material: async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction. """Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
""" """
async with db.begin(): async with db.begin():
duplicate = await db.scalar( duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None: if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name") raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id): if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id") raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material( material = Material(
category_id=payload.category_id,
name=payload.name, name=payload.name,
topic=payload.topic, topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id, canonical_unit_id=payload.canonical_unit_id,
+113 -29
View File
@@ -290,12 +290,78 @@ async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
return (count or 0) > 0 return (count or 0) > 0
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
"""Check candidate material_ids and purge any that have zero references.
A material is considered an orphan if:
1. It is not referenced by ANY line_items across all quotes.
2. It is not referenced by ANY inventory stock items in warehouses.
If count == 0:
- Auxiliary records in supplier_materials, brand_materials, material_units,
and material_attribute_values are cleaned up.
- The material row is deleted from the central materials table.
Returns the list of purged material UUIDs.
"""
purged: list[UUID] = []
unique_ids = {m for m in material_ids if m is not None}
if not unique_ids:
return purged
from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialUnit,
)
from app.models.inventory import InventoryItem
from app.models.procurement import LineItem, SupplierMaterial
for mat_id in unique_ids:
# Check active usage in line_items across the whole system
line_item_count = await db.scalar(
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
)
if (line_item_count or 0) > 0:
continue
# Check active usage in warehouse inventory
inv_count = await db.scalar(
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
)
if (inv_count or 0) > 0:
continue
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
await db.execute(
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
)
await db.execute(
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
)
await db.execute(
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
)
await db.execute(
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
)
await db.execute(
delete(Material).where(Material.id == mat_id)
)
purged.append(mat_id)
logger.info("Purged orphan material %s from central catalog", mat_id)
return purged
async def replace_quote_children( async def replace_quote_children(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote: ) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal). """Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered and has no warehouse receipts. 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(): async with db.begin():
quote = await get_quote_or_404(db, quote_id) quote = await get_quote_or_404(db, quote_id)
@@ -309,6 +375,11 @@ async def replace_quote_children(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.", "این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
field="status", field="status",
) )
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps # children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
await db.execute( await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False) delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
@@ -317,10 +388,21 @@ async def replace_quote_children(
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False) delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
) )
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets) await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
await db.flush()
# 2. Identify detached materials: previous materials no longer present in the updated line items
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
detached_material_ids = old_material_ids - new_material_ids
# 3. Clean up any detached materials that are now orphans (0 usage)
if detached_material_ids:
await cleanup_orphan_materials(db, detached_material_ids)
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor) _add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
return quote return quote
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None: async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered.""" """Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status current = quote.status
@@ -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) li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
await db.execute( await db.execute(
update(ItemDecision) update(ItemDecision)
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending") .where(ItemDecision.line_item_id.in_(li_ids))
.values( .values(
status="pending", status="pending",
decided_at=None, decided_at=None,
@@ -524,21 +606,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
decision.adjusted_notes = decision_in.adjusted_notes decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow() decision.decided_at = utcnow()
# --- per-material project allocation (تخصیص پروژه به هر قلم) --- # LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
for entry in payload.item_projects:
if entry.line_item_id not in li_ids:
raise NotFoundError(
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
)
await db.execute(
delete(LineItemProjects)
.where(LineItemProjects.line_item_id == entry.line_item_id)
.execution_options(synchronize_session=False)
)
for pid in entry.project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
# termsheets referenced by at least one chosen item -> accepted # termsheets referenced by at least one chosen item -> accepted
if chosen_ts_ids: if chosen_ts_ids:
@@ -735,20 +803,36 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all() termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all() events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
# 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.models.files import QuoteAttachment
from app.services import storage from app.services import storage
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all() attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
for att in attachments: att_keys = [att.object_key for att in attachments]
try: quote_prefix = f"{quote.id}/"
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)
await db.delete(quote) await db.delete(quote)
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 { return {
"id": str(quote.id), "id": str(quote_id),
"deleted": True, "deleted": True,
"cascaded": { "cascaded": {
"line_items": len(line_items), "line_items": len(line_items),
@@ -757,10 +841,13 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
"decisions": len(decisions), "decisions": len(decisions),
"receipts": len(receipts), "receipts": len(receipts),
"attachments": len(attachments), "attachments": len(attachments),
"purged_orphan_materials": len(purged_material_ids),
}, },
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Statistics # Statistics
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -816,15 +903,12 @@ async def quote_stats(
def _quote_has_category(category_id: UUID): def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id.""" """EXISTS: quote's supplier belongs to category_id."""
from app.models.catalog import Material
sub = ( sub = (
select(1) select(1)
.where( .where(
LineItem.quote_id == Quote.id, Supplier.id == Quote.supplier_id,
Material.id == LineItem.material_id, Supplier.category_id == category_id,
Material.category_id == category_id,
) )
) )
return func.exists(sub) return func.exists(sub)
+19
View File
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key) await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
except Exception: except Exception:
logger.warning("Could not remove MinIO object %r — ignoring", key) logger.warning("Could not remove MinIO object %r — ignoring", key)
async def remove_prefix(prefix: str) -> None:
"""Delete all objects matching a prefix in the bucket (best-effort)."""
client = get_client()
def _sync_remove_prefix():
try:
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
for obj in objects:
try:
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
except Exception:
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
except Exception:
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
await asyncio.to_thread(_sync_remove_prefix)
@@ -1,10 +1,6 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow. """Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
Linking a material to a supplier derives materials.category_id from the Suppliers can supply any global material across categories.
supplier's category when the material has none (first-link-wins). The same
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
this service applies it in the application layer so the ORM state is
consistent inside the same transaction.
""" """
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material( async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial: ) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins). """Upsert a supplier<->material link (idempotent).
Caller is responsible for the surrounding transaction. Caller is responsible for the surrounding transaction.
""" """
@@ -42,15 +38,11 @@ async def link_supplier_material(
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active) link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link) db.add(link)
await db.flush() await db.flush()
# First-link-wins: only when the material has no category yet.
if material.category_id is None:
material.category_id = supplier.category_id
return link return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None: async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
"""Remove the link. The material's category is NEVER auto-cleared (documented rule).""" """Remove the link."""
link = await db.scalar( link = await db.scalar(
select(SupplierMaterial).where( select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id, SupplierMaterial.supplier_id == supplier_id,
+3 -3
View File
@@ -261,9 +261,7 @@ async def run() -> None:
material_by_name: dict[str, Material] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
category_id=cat.id,
name=name, name=name,
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
@@ -289,8 +287,10 @@ async def run() -> None:
await db.flush() await db.flush()
suppliers.append(sup) suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category # last-quoted prices: link each supplier to 2 materials of its category
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
for sup in suppliers: for sup in suppliers:
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id] target_slug = sup_cat_slug.get(sup.name)
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
for m in cat_materials[:2]: for m in cat_materials[:2]:
db.add(SupplierMaterial( db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True, supplier_id=sup.id, material_id=m.id, is_active=True,
+51 -14
View File
@@ -1,6 +1,7 @@
services: services:
postgres: postgres:
image: postgres:18 image: postgres:18
container_name: procurement-postgres
environment: environment:
POSTGRES_DB: procurement POSTGRES_DB: procurement
@@ -8,7 +9,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
ports: ports:
- "5435:5432" - "5433:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
@@ -23,13 +24,24 @@ services:
restart: unless-stopped restart: unless-stopped
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
# App is served on http://localhost (port 80) instead of :3000.
# Production: see docker-compose.prod.yml (real domains + HTTPS via
# Let's Encrypt, HTTP→HTTPS redirect).
# ==========================================================================
traefik: traefik:
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
# ("Error response from daemon" — Docker API version mismatch).
image: traefik:v3.6 image: traefik:v3.6
container_name: procurement-traefik
restart: unless-stopped restart: unless-stopped
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
- --providers.docker.network=erp_production_network # Containers are on several networks; Traefik must use the dedicated
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml - --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443 - --entrypoints.websecure.address=:443
@@ -39,9 +51,11 @@ services:
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --log.level=INFO - --log.level=INFO
ports: ports:
- "8084:80" - "80:80"
- "8445:443" - "443:443"
volumes: volumes:
# NOTE: mounted read-write — Docker Desktop rejects read-only
# /var/run/docker.sock mounts ("Error response from daemon").
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro - ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs - traefik_certs:/certs
@@ -50,6 +64,7 @@ services:
minio: minio:
image: minio/minio:latest image: minio/minio:latest
container_name: procurement-minio
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
@@ -58,18 +73,20 @@ services:
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: minioadmin
ports: ports:
- "9004:9000" # S3 API - "9000:9000" # S3 API
- "9005:9001" # Web console - "9001:9001" # Web console
volumes: volumes:
- minio_data:/data - minio_data:/data
labels: labels:
- traefik.enable=true - traefik.enable=true
# S3 API
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`) - traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
- traefik.http.routers.minio-api.entrypoints=web - traefik.http.routers.minio-api.entrypoints=web
- traefik.http.routers.minio-api.service=minio-api-svc - traefik.http.routers.minio-api.service=minio-api-svc
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000 - traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
# Web console
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`) - traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
- traefik.http.routers.minio-console.entrypoints=web - traefik.http.routers.minio-console.entrypoints=web
- traefik.http.routers.minio-console.service=minio-console-svc - traefik.http.routers.minio-console.service=minio-console-svc
@@ -81,8 +98,11 @@ services:
restart: unless-stopped restart: unless-stopped
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init: minio-init:
image: minio/mc:latest image: minio/mc:latest
container_name: procurement-minio-init
depends_on: depends_on:
minio: minio:
@@ -105,8 +125,20 @@ services:
restart: "no" restart: "no"
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# is created by backend/tools/seed_casdoor.py — run it once after the
# first boot:
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
#
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
# created by sql/02-casdoor-db.sql on a fresh volume).
# ==========================================================================
casdoor: casdoor:
image: casbin/casdoor:latest image: casbin/casdoor:latest
container_name: procurement-casdoor
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -115,9 +147,9 @@ services:
driverName: postgres driverName: postgres
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor" dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
dbName: casdoor dbName: casdoor
origin: "http://localhost:8083" origin: "http://localhost:8080"
ports: ports:
- "8083:8000" - "8080:8000"
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -135,6 +167,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: procurement-api
environment: environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
@@ -143,18 +176,21 @@ services:
PROCUREMENT_MINIO_SECRET_KEY: minioadmin PROCUREMENT_MINIO_SECRET_KEY: minioadmin
PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false" PROCUREMENT_MINIO_SECURE: "false"
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083 PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026 PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123" PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
ports: ports:
- "8001:8000" - "8000:8000"
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -178,10 +214,9 @@ services:
build: build:
context: ./frontend context: ./frontend
args: args:
# Developer Mode panel on the login page — set false for production.
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true} DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
container_name: procurement-frontend
ports:
- "8085:80"
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -199,8 +234,10 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network: traefik_network:
name: erp_production_network name: traefik_network
driver: bridge driver: bridge
volumes: volumes:
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
} }
function toItem(x: ComboboxSourceItem): ComboboxItem { function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => { onChange={(e) => {
setText(e.target.value); setText(e.target.value);
setHighlight(-1); setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => setOpen(true)} onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
+15 -192
View File
@@ -1,6 +1,5 @@
import { useRef, useState } from "react"; import { useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
import type { Attachment } from "../lib/types"; import type { Attachment } from "../lib/types";
export interface FileListProps { export interface FileListProps {
@@ -15,18 +14,9 @@ export interface FileListProps {
label?: string; label?: string;
} }
const isImageFile = (name: string = "") => { // مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
// مدیریت چند فاکتور: آپلود مستقیم، پیش‌نمایش آنلاین تصویر/PDF و دانلود
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) { export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
const remove = (i: number) => { const remove = (i: number) => {
const removed = files[i]; const removed = files[i];
@@ -49,94 +39,22 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
if (added.length) onChange([...files, ...added]); if (added.length) onChange([...files, ...added]);
}; };
const handleOpenPreview = (inv: Attachment) => {
const url = urlOf?.(inv);
if (!url) {
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
return;
}
setPreviewItem({ url, name: inv.name });
};
return ( return (
<div className="space-y-2"> <div>
{!files.length && ( {!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500"> {files.map((inv, i) => (
هنوز فاکتور یا سندی بارگذاری نشده است. <div key={i} className="inv-row">
</div> <span className="inv-name">📎 {inv.name}</span>
)} {urlOf?.(inv) && (
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
{files.length > 0 && ( ↓
<div className="flex flex-col gap-1.5">
{files.map((inv, i) => {
const url = urlOf?.(inv);
const isImg = isImageFile(inv.name);
const isPdf = isPdfFile(inv.name);
return (
<div
key={inv.id || i}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
>
{/* نام و آیکون فایل — با قابلیت کلیک برای پیش‌نمایش */}
<div
className={`flex items-center gap-2 min-w-0 flex-1 ${url ? "cursor-pointer group" : ""}`}
onClick={() => url && handleOpenPreview(inv)}
title={url ? "مشاهده پیش‌نمایش" : inv.name}
>
{isImg ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : isPdf ? (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
)}
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
{inv.name}
</span>
</div>
{/* دکمه‌های عملیات: پیش‌نمایش، دانلود مستقیم، حذف */}
<div className="flex items-center gap-1 shrink-0 me-1">
{url && (
<>
<button
type="button"
onClick={() => handleOpenPreview(inv)}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors"
title="مشاهده فاکتور"
>
<Eye className="h-3.5 w-3.5" />
</button>
<a
href={url}
download={inv.name}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
title="دانلود فایل"
>
<Download className="h-3.5 w-3.5" />
</a> </a>
</>
)} )}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
<button ×
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
title="حذف فایل"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> ))}
);
})}
</div>
)}
<div> <div>
<input <input
ref={inputRef} ref={inputRef}
@@ -146,105 +64,10 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
className="hidden" className="hidden"
onChange={handleChange} onChange={handleChange}
/> />
<button <button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
type="button" + {label}
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button> </button>
</div> </div>
{/* پنجره مودال پیش‌نمایش (Preview Modal) */}
{previewItem && (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
onClick={() => setPreviewItem(null)}
>
<div
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* هدر پیش‌نمایش */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
<div className="flex items-center gap-2 min-w-0">
{isImageFile(previewItem.name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
)}
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">
{previewItem.name}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<a
href={previewItem.url}
download={previewItem.name}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="دانلود فاکتور"
>
<Download className="h-3.5 w-3.5" />
<span className="hidden xs:inline">دانلود فاکتور</span>
</a>
<a
href={previewItem.url}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="باز کردن در برگه جدید"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={() => setPreviewItem(null)}
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
title="بستن"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* بدنه نمایش فایل */}
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
{isImageFile(previewItem.name) ? (
<img
src={previewItem.url}
alt={previewItem.name}
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
/>
) : isPdfFile(previewItem.name) ? (
<iframe
src={previewItem.url}
title={previewItem.name}
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
/>
) : (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
<FileText className="h-12 w-12 text-gray-400" />
<div className="text-xs text-gray-600">
امکان نمایش پیش‌نمایش زنده برای این فرمت فایل وجود ندارد.
</div>
<a
href={previewItem.url}
download={previewItem.name}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700 transition-colors"
>
<Download className="h-3.5 w-3.5" />
دانلود فایل
</a>
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+184 -36
View File
@@ -6,6 +6,7 @@ import {
jalaliStrToIso, jalaliStrToIso,
jalaliToGregorian, jalaliToGregorian,
MONTHS, MONTHS,
toAsciiDigits,
toFa, toFa,
WEEKDAYS, WEEKDAYS,
} from "../lib/jalali"; } from "../lib/jalali";
@@ -29,7 +30,7 @@ function todayJalali(): [number, number, number] {
function isoToJalali(iso: string | null): [number, number, number] | null { function isoToJalali(iso: string | null): [number, number, number] | null {
if (!iso) return null; if (!iso) return null;
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/); const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) { if (m) {
const gy = parseInt(m[1], 10); const gy = parseInt(m[1], 10);
const gm = parseInt(m[2], 10); const gm = parseInt(m[2], 10);
@@ -78,6 +79,10 @@ export function JalaliDatePicker({
}: JalaliDatePickerProps) { }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const initialTime = timeOf(value);
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
const [text, setText] = useState<string>(() => { const [text, setText] = useState<string>(() => {
if (!value) return ""; if (!value) return "";
const j = isoToJalaliStr(value); const j = isoToJalaliStr(value);
@@ -92,19 +97,28 @@ export function JalaliDatePicker({
useEffect(() => { useEffect(() => {
if (!value) { if (!value) {
setText(""); setText("");
const now = nowTime();
setHourText(faNum(String(now.h).padStart(2, "0")));
setMinuteText(faNum(String(now.m).padStart(2, "0")));
return; return;
} }
const j = isoToJalaliStr(value); const j = isoToJalaliStr(value);
const t = timeOf(value);
if (!withTime) { if (!withTime) {
setText(j); setText(j);
} else { } else {
const d = new Date(value); const hh = String(t.h).padStart(2, "0");
if (Number.isNaN(d.getTime())) { const mm = String(t.m).padStart(2, "0");
setText(j);
} else {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`); 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); const jParts = isoToJalali(value);
@@ -116,14 +130,13 @@ export function JalaliDatePicker({
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم // تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم
function selectedLocalDate(): string | null { function selectedLocalDate(): string | null {
if (!value) return null; if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`; if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(value); const d = new Date(value);
if (Number.isNaN(d.getTime())) return null; if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
} }
const selectedIso = selectedLocalDate(); const selectedIso = selectedLocalDate();
const time = timeOf(value);
const grid = useMemo(() => { const grid = useMemo(() => {
const [jy, jm] = curJ; const [jy, jm] = curJ;
@@ -163,36 +176,108 @@ export function JalaliDatePicker({
}; };
const selectDay = (jy: number, jm: number, jd: number) => { 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); onChange(val);
setText( 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); setOpen(false);
}; };
const setTimePart = (part: "h" | "m", raw: string) => { const handleHourChange = (raw: string) => {
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10); let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (Number.isNaN(n)) return; if (digits.length === 3) {
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h; digits = digits.slice(-1);
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m; } else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) {
setHourText("");
return;
}
const n = parseInt(digits, 10);
const clamped = Math.min(23, Math.max(0, n));
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
setHourText(faNum(textToShow));
const [jy, jm, jd] = curJ; const [jy, jm, jd] = curJ;
onChange(buildIso(jy, jm, jd, h, m)); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
// به‌روزرسانی نمایش const validM = Math.min(59, Math.max(0, currentM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m)); onChange(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`); const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(23, Math.max(0, n));
setHourText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleMinuteChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) {
setMinuteText("");
return;
}
const n = parseInt(digits, 10);
const clamped = Math.min(59, Math.max(0, n));
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
setMinuteText(faNum(textToShow));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
};
const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(59, Math.max(0, n));
setMinuteText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
}; };
const pickToday = () => { const pickToday = () => {
const now = new Date(); const now = new Date();
const [jy, jm, jd] = todayJalali(); const [jy, jm, jd] = todayJalali();
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 const val = withTime
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes()) ? buildIso(jy, jm, jd, nh, nm)
: buildDateOnly(jy, jm, jd); : buildDateOnly(jy, jm, jd);
onChange(val); onChange(val);
setText( setText(
withTime withTime
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}` ? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}`
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`, : `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
); );
setOpen(false); setOpen(false);
@@ -201,17 +286,24 @@ export function JalaliDatePicker({
const clear = () => { const clear = () => {
onChange(null); onChange(null);
setText(""); setText("");
const now = nowTime();
setHourText(faNum(String(now.h).padStart(2, "0")));
setMinuteText(faNum(String(now.m).padStart(2, "0")));
setOpen(false); setOpen(false);
}; };
const onInputChange = (v: string) => { const onInputChange = (v: string) => {
setText(v); setText(v);
if (!v.trim()) {
onChange(null);
return;
}
const iso = jalaliStrToIso(v); const iso = jalaliStrToIso(v);
if (iso) { if (iso) {
if (!withTime) { if (!withTime) {
const nums = String(v) const nums = String(v)
.split(/[/\-.]/) .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))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`); 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 ( return (
<div ref={ref} className={cn("relative", className)}> <div ref={ref} className={cn("relative", className)}>
<input <input
@@ -238,6 +375,7 @@ export function JalaliDatePicker({
onFocus={() => !disabled && setOpen(true)} onFocus={() => !disabled && setOpen(true)}
onClick={() => !disabled && setOpen(true)} onClick={() => !disabled && setOpen(true)}
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onBlur={handleInputBlur}
/> />
{open && !disabled && ( {open && !disabled && (
<div <div
@@ -248,6 +386,7 @@ export function JalaliDatePicker({
withTime ? "w-80 p-3" : "w-64 p-2", withTime ? "w-80 p-3" : "w-64 p-2",
)} )}
> >
{/* 1. Header: Month / Year Navigation */}
<div className="mb-1.5 flex items-center justify-between gap-1"> <div className="mb-1.5 flex items-center justify-between gap-1">
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}> <button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
« «
@@ -265,6 +404,8 @@ export function JalaliDatePicker({
» »
</button> </button>
</div> </div>
{/* 2. Day of Week Header & Days Grid */}
<div className="grid grid-cols-7 gap-0.5 text-center"> <div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((w) => ( {WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-muted"> <div key={w} className="py-0.5 text-[10px] font-bold text-muted">
@@ -291,33 +432,40 @@ export function JalaliDatePicker({
), ),
)} )}
</div> </div>
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
{withTime && ( {withTime && (
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5"> <div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
<input <input
type="text" type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16" className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
value={faNum(String(time.h).padStart(2, "0"))} value={minuteText}
inputMode="numeric" inputMode="numeric"
maxLength={2} onFocus={(e) => e.target.select()}
onChange={(e) => setTimePart("h", e.target.value)} onChange={(e) => handleMinuteChange(e.target.value)}
onBlur={handleMinuteBlur}
/> />
</div>
<span className="text-muted">:</span>
<div className="flex items-center gap-1.5">
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span> <span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
</div>
<span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5">
<input <input
type="text" type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16" className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
value={faNum(String(time.m).padStart(2, "0"))} value={hourText}
inputMode="numeric" inputMode="numeric"
maxLength={2} onFocus={(e) => e.target.select()}
onChange={(e) => setTimePart("m", e.target.value)} onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur}
/> />
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
</div> </div>
</div> </div>
)} )}
{/* 4. Action Buttons Footer */}
<div className="mt-2 flex justify-between border-t border-line pt-2"> <div className="mt-2 flex justify-between border-t border-line pt-2">
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}> <button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
امروز امروز
+4 -15
View File
@@ -12,19 +12,15 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return ( return (
<div className="flex items-start gap-3 w-full my-2"> <div className="milestone-row">
{/* کادر انتخاب تاریخ */}
<div className="w-36 shrink-0">
<JalaliDatePicker <JalaliDatePicker
value={milestone.when_iso} value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
placeholder="تاریخ" placeholder="تاریخ"
className="w-36 shrink-0"
disabled={disabled} disabled={disabled}
/> />
</div> <div className="ms-amount-wrap">
{/* کادر ورود مبلغ و حروف زیر آن */}
<div className="flex-1 min-w-0">
<NumberInput <NumberInput
value={milestone.amount} value={milestone.amount}
onChange={(amount) => onChange({ ...milestone, amount })} onChange={(amount) => onChange({ ...milestone, amount })}
@@ -33,15 +29,8 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
disabled={disabled} disabled={disabled}
/> />
</div> </div>
{/* دکمه حذف */}
{!disabled && ( {!disabled && (
<button <button type="button" className="ms-x" title="حذف" onClick={onRemove}>
type="button"
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
title="حذف"
onClick={onRemove}
>
× ×
</button> </button>
)} )}
+12 -48
View File
@@ -1,27 +1,15 @@
import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react"; import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
import { fmtNum } from "../lib/formatters"; import { fmtNum } from "../lib/formatters";
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
export interface StatsBarProps { export interface StatsBarProps {
total: number; total: number;
offered: number; offered: number;
accepted: number; accepted: number;
arrived: number; avgLeadTime: number | null; // روز
activeFilter?: StatusFilter;
onFilterChange?: (filter: StatusFilter) => void;
} }
export function StatsBar({ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
total,
offered,
accepted,
arrived,
activeFilter = "all",
onFilterChange,
}: StatsBarProps) {
const stats: { const stats: {
key: StatusFilter;
label: string; label: string;
shortLabel: string; shortLabel: string;
value: string; value: string;
@@ -30,7 +18,6 @@ export function StatsBar({
icon: LucideIcon; icon: LucideIcon;
}[] = [ }[] = [
{ {
key: "all",
label: "کل پیشنهادها", label: "کل پیشنهادها",
shortLabel: "کل", shortLabel: "کل",
value: fmtNum(total), value: fmtNum(total),
@@ -38,7 +25,6 @@ export function StatsBar({
icon: ClipboardList, icon: ClipboardList,
}, },
{ {
key: "offered",
label: "پیشنهاد باز", label: "پیشنهاد باز",
shortLabel: "باز", shortLabel: "باز",
value: fmtNum(offered), value: fmtNum(offered),
@@ -46,7 +32,6 @@ export function StatsBar({
icon: Inbox, icon: Inbox,
}, },
{ {
key: "accepted",
label: "پذیرفته‌شده", label: "پذیرفته‌شده",
shortLabel: "پذیرفته", shortLabel: "پذیرفته",
value: fmtNum(accepted), value: fmtNum(accepted),
@@ -54,41 +39,21 @@ export function StatsBar({
icon: CheckCircle2, icon: CheckCircle2,
}, },
{ {
key: "arrived", label: "میانگین زمان تحویل",
label: "تحویل‌شده به کارگاه", shortLabel: "میانگین تحویل",
shortLabel: "تحویل", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
value: fmtNum(arrived), unit: avgLeadTime != null ? "روز" : undefined,
color: "text-green", color: "text-green",
icon: PackageCheck, icon: Timer,
}, },
]; ];
const handleClick = (key: StatusFilter) => {
if (!onFilterChange) return;
if (key === "all") {
onFilterChange("all");
} else {
onFilterChange(activeFilter === key ? "all" : key);
}
};
return ( return (
<section className="grid grid-cols-4 gap-1.5 sm:gap-3"> <section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => { {stats.map((s) => (
const isClickable = Boolean(onFilterChange);
const isActive = activeFilter === s.key;
return (
<div <div
key={s.label} key={s.label}
onClick={() => handleClick(s.key)} className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
isClickable ? "cursor-pointer select-none" : ""
} ${
isActive
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
: "border-line bg-panel hover:border-accent/40"
}`}
> >
{/* عنوان کارت */} {/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between"> <div className="flex items-center justify-center sm:justify-between">
@@ -103,7 +68,7 @@ export function StatsBar({
</span> </span>
</div> </div>
{/* مقدار عددی */} {/* مقدار عددی به همراه واحد روز */}
<div <div
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`} className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
> >
@@ -115,8 +80,7 @@ export function StatsBar({
)} )}
</div> </div>
</div> </div>
); ))}
})}
</section> </section>
); );
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export function useAddMaterial() {
const invalidate = useInvalidateStore(); const invalidate = useInvalidateStore();
return useMutation({ return useMutation({
mutationFn: (m: Material) => api.materials.create(m), mutationFn: (m: Material) => api.materials.create(m),
onMutate: (m) => addMaterial(m), onSuccess: (created) => addMaterial(created),
onSettled: () => invalidate(), onSettled: () => invalidate(),
}); });
} }
+119 -37
View File
@@ -14,6 +14,7 @@
import { normalize } from "./normalize"; import { normalize } from "./normalize";
import { buildTopics } from "./topics"; import { buildTopics } from "./topics";
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
@@ -33,7 +34,7 @@ import type {
Warehouse, Warehouse,
} from "./types"; } from "./types";
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار // حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
expiry_date: "تاریخ انقضا", expiry_date: "تاریخ انقضا",
accepted_date: "تاریخ پذیرش", accepted_date: "تاریخ پذیرش",
arrived_date: "تاریخ رسید", arrived_date: "تاریخ رسید",
due_date: "تاریخ سررسید",
schedule_steps: "مراحل پرداخت",
milestones: "مراحل پرداخت",
amount: "مبلغ",
step_no: "شماره مرحله",
}; };
const faField = (field: string): string => { export const faField = (field: string): string => {
const last = field.split(".").filter(Boolean).pop() || field; const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
const last = parts.pop() || field;
return FIELD_FA[last] || FIELD_FA[field] || field; return FIELD_FA[last] || FIELD_FA[field] || field;
}; };
// ترجمه‌ی خطای HTTP بر اساس کد وضعیت // ترجمه‌ی خطای HTTP بر اساس کد وضعیت — پیام‌های معنادار بک‌اند حفظ می‌شوند
const faHttpError = (status: number, fallback: string): string => { export const faHttpError = (status: number, fallback: string): string => {
if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است"; if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است";
if (status === 404) return "مورد درخواستی یافت نشد"; if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر"; if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
if (status === 400) return "داده‌ی ارسال‌شده نامعتبر است"; if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "داده‌ی ارسال‌شده نامعتبر است";
return fallback; return fallback;
}; };
export class ApiError extends Error {
status: number;
code?: string;
field?: string;
constructor(message: string, status: number, code?: string, field?: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.field = field;
}
}
// ============================================================ // ============================================================
// HTTP helpers — envelope بک‌اند: {"data": ..., "meta": ...} // HTTP helpers — envelope بک‌اند: {"data": ..., "meta": ...}
// ============================================================ // ============================================================
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
headers: authHeaders(init), headers: authHeaders(init),
}); });
} catch { } catch {
throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟"); throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
} }
if (!res.ok) { if (!res.ok) {
let msg = `خطای سرور (${res.status})`; let msg = `خطای سرور (${res.status})`;
let errCode: string | undefined;
let errField: string | undefined;
try { try {
const body = await res.json(); const body = await res.json();
if (body?.error?.message) { if (body?.error) {
msg = body.error.message; errCode = body.error.code;
errField = body.error.field;
const rawMsg = body.error.message || "";
const fName = errField ? faField(errField) : "";
if (fName && rawMsg) {
msg = `داده‌ی «${fName}» نامعتبر است: ${rawMsg}`;
} else if (rawMsg) {
msg = rawMsg;
}
} else if (Array.isArray(body?.detail)) { } else if (Array.isArray(body?.detail)) {
// FastAPI 422 — آرایه‌ی خطاهای اعتبارسنجی با پیام انگلیسی // FastAPI 422 — آرایه‌ی خطاهای اعتبارسنجی با پیام انگلیسی
const first = body.detail[0]; const first = body.detail[0];
const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : ""; const loc = first?.loc || [];
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
errField = filteredLoc.join(".");
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "داده‌ی ارسال‌شده نامعتبر است"; const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "داده‌ی ارسال‌شده نامعتبر است";
msg = field ? `داده‌ی «${faField(field)}» نامعتبر است: ${fa}` : fa; const fName = fieldName ? faField(fieldName) : "";
msg = fName ? `داده‌ی «${fName}» نامعتبر است: ${fa}` : fa;
} else if (body?.detail) { } else if (body?.detail) {
msg = String(body.detail); msg = String(body.detail);
} }
} catch { } catch {
/* بدنۀ خطا JSON نیست */ /* بدنۀ خطا JSON نیست */
} }
throw new Error(faHttpError(res.status, msg)); const finalMsg = faHttpError(res.status, msg);
throw new ApiError(finalMsg, res.status, errCode, errField);
} }
return (await res.json()) as T; return (await res.json()) as T;
} }
@@ -668,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
function toDateOnly(v: string | null | undefined): string | null { function toDateOnly(v: string | null | undefined): string | null {
if (!v) return null; if (!v) return null;
const str = String(v).trim(); const str = toAsciiDigits(v).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str; if (!str) return null;
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب می‌افتد)
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سال‌های ۱۳۰۰ تا ۱۵۰۰)
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); const d = new Date(str);
if (Number.isNaN(d.getTime())) return null; if (Number.isNaN(d.getTime())) return null;
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
} }
function toDateTime(v: string | null | undefined): string | null { function toDateTime(v: string | null | undefined): string | null {
const d = toDateOnly(v); if (!v) return null;
if (!d) return null; const str = toAsciiDigits(v).trim();
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string); if (!str) return null;
// ۱. تاریخ جلالی با زمان اختیاری
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
if (jm) {
const jy = parseInt(jm[1], 10);
const jmon = parseInt(jm[2], 10);
const jd = parseInt(jm[3], 10);
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
}
// ۲. اگر میلادی استاندارد ISO است
const d = new Date(str);
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
}
const dateOnly = toDateOnly(str);
return dateOnly ? dateOnly + "T00:00:00Z" : null;
} }
function todayIso(): string { function todayIso(): string {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
function looksLikeUuid(v: string): boolean { export function looksLikeUuid(v: string | null | undefined): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); if (!v) return false;
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
} }
// ============================================================ // ============================================================
@@ -777,6 +857,13 @@ export const api = {
// مقدار پیش‌فرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده») // مقدار پیش‌فرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
// پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند) // پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند)
for (let i = 0; i < lineItems.length; i++) { for (let i = 0; i < lineItems.length; i++) {
let matId = realMaterialId(lineItems[i].material_id);
if (!looksLikeUuid(matId)) {
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
if (found) matId = found.id;
}
lineItems[i].material_id = matId;
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit); lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
for (const p of q.line_items[i].projects || []) { for (const p of q.line_items[i].projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
@@ -870,7 +957,6 @@ export const api = {
if (pid) targetProjByLine.set(li.id, pid); if (pid) targetProjByLine.set(li.id, pid);
} }
} }
const allProjectIds = [...new Set([...targetProjByLine.values()])];
const payload = { const payload = {
accepted_termsheets: acceptedTs.map((t) => ({ accepted_termsheets: acceptedTs.map((t) => ({
@@ -899,11 +985,7 @@ export const api = {
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند
}; };
await patchData(`/quotes/${id}/accept`, payload); await patchData(`/quotes/${id}/accept`, payload);
// پروژه‌های سطح پیشنهاد با اتحاد پروژه‌های اقلام همگام می‌شوند return { ...current, ...patch, projects: current.projects } as Quote;
if (allProjectIds.length) {
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
}
return { ...current, ...patch } as Quote;
} }
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata --- // --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
@@ -1026,12 +1108,6 @@ export const api = {
}, },
create: async (m: Material): Promise<Material> => { create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup(); const lookup = await fetchLookup();
// v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت
// نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً
// از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins).
// دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛
// در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم.
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود // واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// (بک‌اند canonical_unit_id را الزامی می‌داند) // (بک‌اند canonical_unit_id را الزامی می‌داند)
const unitName = (m.base_unit || "").trim(); const unitName = (m.base_unit || "").trim();
@@ -1056,7 +1132,6 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec }); if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
} }
const raw = await postData<RawMaterial>("/materials", { const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId ?? null,
name, name,
topic: m.topic || null, topic: m.topic || null,
canonical_unit_id: unitId, canonical_unit_id: unitId,
@@ -1259,6 +1334,10 @@ async function fetchProjects(): Promise<RawProject[]> {
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> { async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
if (!name) return null; if (!name) return null;
const projects = await fetchProjects(); const projects = await fetchProjects();
if (looksLikeUuid(name)) {
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
if (byId) return byId.id;
}
const pid = projectIdByName(projects, name); const pid = projectIdByName(projects, name);
if (pid) return pid; if (pid) return pid;
const raw = await postData<RawProject>("/projects", { name }); const raw = await postData<RawProject>("/projects", { name });
@@ -1390,8 +1469,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بک‌اند) // utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بک‌اند)
export function nextId(prefix: string, items: { id: string }[]): string { export function nextId(prefix: string, items: { id: string }[]): string {
const regex = new RegExp(`^${prefix}(\\d+)$`);
const max = items const max = items
.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10)) .map((x) => {
const m = String(x.id).match(regex);
return m ? parseInt(m[1], 10) : 0;
})
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
.reduce((a, b) => Math.max(a, b), 0); .reduce((a, b) => Math.max(a, b), 0);
return prefix + (max + 1); return prefix + (max + 1);
@@ -1670,7 +1753,6 @@ export const apiV26 = {
}): Promise<{ id: string; name: string }> => }): Promise<{ id: string; name: string }> =>
postData("/materials", { postData("/materials", {
name: opts.name, name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null, topic: null,
canonical_unit_id: opts.canonical_unit_id, canonical_unit_id: opts.canonical_unit_id,
status: "active", status: "active",
+21 -5
View File
@@ -99,8 +99,11 @@ export const MONTHS = [
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"]; export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
function toEnSafe(s: string): string { export function toAsciiDigits(s: string | number | null | undefined): string {
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))); if (s == null) return "";
return String(s)
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
} }
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲" // format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
@@ -121,17 +124,30 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`; return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
} }
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight // parse a jalali "yyyy/m/d" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string
export function jalaliStrToIso(str: string | null | undefined): string | null { export function jalaliStrToIso(str: string | null | undefined): string | null {
if (!str) return null; if (!str) return null;
const nums = String(str) const s = toAsciiDigits(str).trim();
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(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toEnSafe(x), 10)); .map((x) => parseInt(x, 10));
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null; if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
const [jy, jm, jd] = nums; const [jy, jm, jd] = nums;
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dt = new Date(gy, gm - 1, gd); const dt = new Date(gy, gm - 1, gd);
return Number.isNaN(dt.getTime()) ? null : dt.toISOString(); 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, h, min, sec);
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
} }
export function daysInMonth(jy: number, jm: number): number { export function daysInMonth(jy: number, jm: number): number {
+31 -43
View File
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|"); return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
} }
// specها با جداکننده‌ی « · » // specها با جداکننده‌ی « . »
export function specsLabel(specs: string[]): string { export function specsLabel(specs: string[]): string {
return specs.join(" · "); return specs.join(" . ");
} }
/** /**
* عنوان تمیز و بدون تکرار کالا: * عنوان تمیز و بدون تکرار کالا:
* ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک. * ترتیب اکید با جداکننده‌ی نقطه:
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
*/ */
export function formatMaterialTitle( export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined, m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
extraSpecs?: string[], extraSpecs?: string[],
): string { ): string {
if (!m) return "کالا"; if (!m) return "کالا";
const rawName = (m.name || "").trim(); let rawName = (m.name || "").trim();
const rawBrand = (m.brand || "").trim(); const rawBrand = (m.brand || "").trim();
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])] const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
.map((s) => (s || "").trim()) .map((s) => (s || "").trim())
.filter(Boolean); .filter(Boolean);
const normName = normalize(rawName);
const normBrand = normalize(rawBrand); const normBrand = normalize(rawBrand);
if (!rawName) { // اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`; if (normBrand && rawName) {
if (rawBrand) return rawBrand; const normName = normalize(rawName);
if (rawSpecs.length) return rawSpecs.join(" · "); if (normName.startsWith(normBrand)) {
return m.category && m.category !== "؟" ? m.category : "کالا"; const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
}
} }
let title = rawName; // قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
if (normBrand && !normName.includes(normBrand)) { const parts: string[] = [];
title = `${rawBrand} ${title}`; if (rawBrand) {
parts.push(rawBrand);
}
if (rawName) {
parts.push(rawName);
} else if (!rawBrand) {
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
} }
const normTitle = normalize(title); // اضافه کردن مشخصه‌ها بدون تکرار
const distinctSpecs: string[] = []; const normCurrent = normalize(parts.join(" "));
const seenNormSpecs = new Set<string>(); const seenNormSpecs = new Set<string>();
for (const s of rawSpecs) { for (const s of rawSpecs) {
@@ -79,15 +87,12 @@ export function formatMaterialTitle(
if (!ns || seenNormSpecs.has(ns)) continue; if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns); seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم // اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم
if (normTitle.includes(ns)) continue; if (normCurrent.includes(ns)) continue;
distinctSpecs.push(s); parts.push(s);
} }
if (distinctSpecs.length > 0) { return parts.join(" . ");
return `${title} · ${distinctSpecs.join(" · ")}`;
}
return title;
} }
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string { export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
@@ -147,17 +152,10 @@ export function lineItemTotal(li: LineItem): number | null {
// - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id) // - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id)
export function itemProjects(q: Quote, li: LineItem): string[] { export function itemProjects(q: Quote, li: LineItem): string[] {
if (q.status === "accepted" || q.status === "arrived") { if (q.status === "accepted" || q.status === "arrived") {
if (li.target_project?.name) { return li.target_project?.name ? [li.target_project.name] : [];
return [li.target_project.name];
} }
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تک‌پروژه بودن // For 'offered', 'rejected', 'expired': return all candidate projects
if (li.projects && li.projects.length === 1) { return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
const p = li.projects[0];
return [typeof p === "string" ? p : p.name];
}
return [];
}
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
} }
// ---------- واحد مرجع و ضریب تبدیل ---------- // ---------- واحد مرجع و ضریب تبدیل ----------
@@ -288,13 +286,8 @@ export function filterQuotes(
"پیشنهاد شده": "offered", "پیشنهاد شده": "offered",
"پذیرفته شده": "accepted", "پذیرفته شده": "accepted",
"رسیده": "arrived", "رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
}; };
const stKey = STATUS_BY_LABEL[stVal] || stVal; const stKey = STATUS_BY_LABEL[stVal] || "";
return store.quotes.filter((quote) => { return store.quotes.filter((quote) => {
const supObj = supplierById(store.suppliers, quote.supplier_id); const supObj = supplierById(store.suppliers, quote.supplier_id);
@@ -318,16 +311,11 @@ export function filterQuotes(
export function isExpired(q: Quote): boolean { export function isExpired(q: Quote): boolean {
return ( return (
q.status === "offered" && q.status === "offered" &&
!quoteHasArrivedOrAccepted(q) &&
!!q.expiry_date && !!q.expiry_date &&
new Date(q.expiry_date) < new Date(new Date().toDateString()) new Date(q.expiry_date) < new Date(new Date().toDateString())
); );
} }
function quoteHasArrivedOrAccepted(q: Quote): boolean {
return q.status === "accepted" || q.status === "arrived";
}
// آماده‌سازی نمایش ترم‌شیت در حالت expanded // آماده‌سازی نمایش ترم‌شیت در حالت expanded
export function termSheetBlocks( export function termSheetBlocks(
store: Store, store: Store,
+2 -2
View File
@@ -20,9 +20,9 @@ export interface Supplier {
// ماده/کالا // ماده/کالا
export interface Material { export interface Material {
id: string; // "m1", "m2", ... id: string; // "m1", "m2", ...
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + دسته + مشخصه) name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + مشخصه)
topic: string; topic: string;
category: string; // مثلاً "میلگرد" category: string; // اختیاری در بک‌اند؛ همیشه string در فرانت (default: "")
brand: string; // مثلاً "ذوب آهن" brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه base_unit: string; // واحد پایه
+74 -97
View File
@@ -36,6 +36,7 @@ export interface AcceptModalProps {
onClose: () => void; onClose: () => void;
} }
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -50,10 +51,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [proformas, setProformas] = useState<Attachment[]>([]); const [proformas, setProformas] = useState<Attachment[]>([]);
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set()); const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({}); const [assignments, setAssignments] = useState<Record<string, string>>({});
// تخصیص هر قلم به پروژه‌ها (نام‌ها)
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({}); const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
// تاریخ و زمان پذیرش — پیش‌فرض الان
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString()); const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
// وقتی quote باز شد، state اولیه را بساز
useMemo(() => { useMemo(() => {
if (!q) return; if (!q) return;
const offered = q.termsheets.filter((t) => t.status === "offered"); const offered = q.termsheets.filter((t) => t.status === "offered");
@@ -66,6 +70,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
setRemovedItems(rem); setRemovedItems(rem);
setAssignments({}); setAssignments({});
// پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد
const projSel: Record<string, string> = {}; const projSel: Record<string, string> = {};
q.line_items.forEach((li) => { q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean); const names = (li.projects || []).map((p) => p.name).filter(Boolean);
@@ -81,6 +86,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setReady(true); setReady(true);
}, [q]); }, [q]);
// پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => { useEffect(() => {
if (!quoteId) return; if (!quoteId) return;
let alive = true; let alive = true;
@@ -103,6 +109,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const allItems = q.line_items || []; const allItems = q.line_items || [];
const active = activeItems(q); const active = activeItems(q);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها
const projectOptions = (() => { const projectOptions = (() => {
const set = new Set<string>(); const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name)); projects.forEach((p) => p.name && set.add(p.name));
@@ -140,8 +147,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const confirm = () => { const confirm = () => {
if (!q) return; if (!q) return;
// پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store)
const next: Quote = JSON.parse(JSON.stringify(q)); const next: Quote = JSON.parse(JSON.stringify(q));
// فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>(); const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
cards.forEach((c, idx) => { cards.forEach((c, idx) => {
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null); const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
@@ -155,10 +164,11 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
if (!cardMeta.size) { if (!cardMeta.size) {
toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است."); toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
return; return;
} }
// رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true; if (removedItems.has(li.id)) li.rejected = true;
}); });
@@ -168,6 +178,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return; return;
} }
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند
const fallbackDom = [...cardMeta.keys()][0]; const fallbackDom = [...cardMeta.keys()][0];
const chosenDomIds = new Set<string>(); const chosenDomIds = new Set<string>();
active.forEach((li) => { active.forEach((li) => {
@@ -189,6 +200,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
} }
}); });
// قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود
active.forEach((li) => { active.forEach((li) => {
const dom = assignments[li.id] || fallbackDom; const dom = assignments[li.id] || fallbackDom;
const finalId = domToFinal[dom]; const finalId = domToFinal[dom];
@@ -198,6 +210,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
li.accepted_ts_id = finalId || null; li.accepted_ts_id = finalId || null;
}); });
// termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered
const usedIds = new Set<string>(); const usedIds = new Set<string>();
active.forEach((li) => { active.forEach((li) => {
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id); if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
@@ -206,18 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
}); });
const projUnion = new Set<string>(); // پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.rejected) return; if (li.rejected) return;
const name = itemProjectsSel[li.id]; const name = itemProjectsSel[li.id];
if (name === undefined) return; if (name === undefined) return;
li.target_project = name ? { id: "", name } : null; li.target_project = name ? { id: "", name } : null;
if (name) projUnion.add(name);
}); });
if (projUnion.size > 0) {
next.projects = [...projUnion];
next.project = next.projects[0] || "";
}
const now = new Date().toISOString(); const now = new Date().toISOString();
next.accepted_date = acceptDate || now; next.accepted_date = acceptDate || now;
@@ -233,37 +242,26 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return ( return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl"> <DialogContent className="max-w-2xl">
<div className="flex flex-col max-h-[85vh] w-full"> <DialogHeader>
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
{/* هدر ثابت و هم‌تراز */}
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right">
<DialogTitle className="text-right text-lg font-bold text-slate-800">
پذیرش پیشنهاد — {sup.name}
</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید. <div className="text-[12px] text-muted">
</p> شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
</div> </div>
{/* بدنه اسکرول‌پذیر */} {/* کارت‌های termsheet */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5"> <div className="flex flex-col gap-2">
{/* بخش ۱: تنظیم شرایط پرداخت */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
<div className="flex flex-col gap-4">
{cards.map((c, idx) => ( {cards.map((c, idx) => (
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3"> <div key={c.domId} className="modal-variant">
<div className="modal-variant-head mb-2 flex items-center justify-between"> <div className="modal-variant-head">
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span> <span className="variant-num">{cardLabel(c, idx)}</span>
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}> <button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
× ×
</button> </button>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-1.5">
{c.milestones.map((m, i) => ( {c.milestones.map((m, i) => (
<MilestoneRow <MilestoneRow
key={i} key={i}
@@ -277,35 +275,31 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/> />
))} ))}
</div> </div>
<Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}> <Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت جدید + مرحله پرداخت
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}> <Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
+ افزودن شرایط پرداخت جدید + نوشتن conditions جدید
</Button> </Button>
</section>
{/* بخش ۲: تخصیص اقلام */} {/* تخصیص قلم‌ها */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5"> <div className="border-t border-line pt-3">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3> <div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به conditions:</div>
<div className="mb-4 text-[12px] text-muted"> <div className="mb-2 text-[11.5px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود. قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div> </div>
<div className="flex flex-col gap-2"> <div>
{allItems.map((li) => { {allItems.map((li) => {
const removed = removedItems.has(li.id); const removed = removedItems.has(li.id);
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
return ( return (
<div <div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
key={li.id}
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
>
<input <input
type="checkbox" type="checkbox"
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0" className="accent-accent"
checked={!removed} checked={!removed}
title="تیک = در معامله می‌ماند" title="تیک = در معامله می‌ماند"
onChange={(e) => { onChange={(e) => {
@@ -315,20 +309,16 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(next); setRemovedItems(next);
}} }}
/> />
<div className="flex-1 min-w-[120px]"> <span className="flex-1 text-[13px]">
<div className="text-[13.5px] font-medium text-slate-800">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</div> </span>
<div className="text-[12px] text-muted mt-0.5"> <span className="text-[12px] text-muted">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
</div> </span>
</div>
<select <select
className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`} className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
value={assignments[li.id] || cards[0]?.domId || ""} value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => assign(li.id, e.target.value)} onChange={(e) => assign(li.id, e.target.value)}
disabled={removed}
> >
{cards.map((c, idx) => ( {cards.map((c, idx) => (
<option key={c.domId} value={c.domId}> <option key={c.domId} value={c.domId}>
@@ -340,37 +330,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
); );
})} })}
{active.length === 0 && ( {active.length === 0 && (
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2"> <div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div>
<span>⚠️</span> همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
</div>
)} )}
</div> </div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-4 text-[12px] text-muted">
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* تخصیص قلم‌ها به پروژه */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div>
<div className="mb-2 text-[11.5px] text-muted">
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
</div>
<div className="flex flex-col gap-2">
{allItems {allItems
.filter((li) => !removedItems.has(li.id)) .filter((li) => !removedItems.has(li.id))
.map((li) => { .map((li) => {
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
return ( return (
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors"> <div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-3 flex items-center justify-between"> <div className="mb-1.5 text-[12.5px]">
<span className="text-[13px] font-semibold text-slate-800 truncate"> <span className="font-semibold">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</span> </span>
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap"> <span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit} {fmtNum(li.quantity)} {li.unit}
</span> </span>
</div> </div>
{projectOptions.length ? ( {projectOptions.length ? (
<select <select
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all" className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
value={itemProjectsSel[li.id] ?? ""} value={itemProjectsSel[li.id] ?? ""}
onChange={(e) => onChange={(e) =>
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value })) setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
@@ -388,18 +376,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
))} ))}
</select> </select>
) : ( ) : (
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div> <div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div>
)} )}
</div> </div>
); );
})} })}
</div> </div>
</section> </div>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} {/* فاکتورهای اصلی */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6"> <div className="border-t border-line pt-3">
<div> <div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -414,10 +401,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/> />
</div> </div>
<div className="h-px bg-line w-full"></div> {/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3">
<div> <div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<FileList <FileList
files={proformas} files={proformas}
onChange={setProformas} onChange={setProformas}
@@ -433,29 +419,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/> />
</div> </div>
<div className="h-px bg-line w-full"></div> {/* تاریخ و زمان پذیرش */}
<div className="border-t border-line pt-3">
<div className="max-w-xs"> <div className="field max-w-xs">
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label> <label>تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime /> <JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div> </div>
</section>
</div> </div>
{/* فوتر ثابت */} <DialogFooter>
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10"> <Button variant="slate" onClick={onClose}>
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف انصراف
</Button> </Button>
<Button onClick={confirm} className="w-full sm:w-auto"> <Button onClick={confirm}>ثبت معامله بسته‌شده</Button>
ثبت معامله بسته‌شده
</Button>
</DialogFooter> </DialogFooter>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+9 -23
View File
@@ -11,8 +11,9 @@ import {
} from "../../hooks"; } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { filterQuotes } from "../../lib/quoteUtils"; import { filterQuotes } from "../../lib/quoteUtils";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { StatsBar, type StatusFilter } from "../../components/StatsBar"; import { StatsBar } from "../../components/StatsBar";
import { FilterToolbar, type Filters } from "./FilterToolbar"; import { FilterToolbar, type Filters } from "./FilterToolbar";
import { LedgerTable } from "./LedgerTable"; import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
@@ -55,31 +56,18 @@ export function DashboardPage() {
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b))); return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
}, [store, filters, sortDir]); }, [store, filters, sortDir]);
// شمارش تفکیکی پیشنهادها بر اساس پایپ‌لاین خرید
const stats = useMemo(() => { const stats = useMemo(() => {
const total = quotes.length; const total = quotes.length;
const offered = quotes.filter((q) => q.status === "offered").length; const offered = quotes.filter((q) => q.status === "offered").length;
const accepted = quotes.filter((q) => q.status === "accepted").length; const accepted = quotes.filter((q) => q.status === "accepted").length;
const arrived = quotes.filter((q) => q.status === "arrived").length; const leadTimes = quotes
return { total, offered, accepted, arrived }; .filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
return { total, offered, accepted, avgLead };
}, [quotes]); }, [quotes]);
// وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
const activeStatusFilter: StatusFilter = useMemo(() => {
if (filters.status === "offered") return "offered";
if (filters.status === "accepted") return "accepted";
if (filters.status === "arrived") return "arrived";
return "all";
}, [filters.status]);
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
const handleStatusFilterChange = (status: StatusFilter) => {
setFilters((prev) => ({
...prev,
status: status === "all" ? "" : status,
}));
};
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]); const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
@@ -131,9 +119,7 @@ export function DashboardPage() {
total={stats.total} total={stats.total}
offered={stats.offered} offered={stats.offered}
accepted={stats.accepted} accepted={stats.accepted}
arrived={stats.arrived} avgLeadTime={stats.avgLead}
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/> />
<FilterToolbar <FilterToolbar
filters={filters} filters={filters}
+221 -333
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react"; import { Trash2, AlertCircle } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types"; import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
import { api, nextDashedId, nextId } from "../../lib/api"; import { api, nextDashedId, nextId } from "../../lib/api";
@@ -37,6 +37,7 @@ import {
interface EditItemRow { interface EditItemRow {
key: string; key: string;
origLi?: LineItem; origLi?: LineItem;
projects?: { id: string; name: string }[];
cat: string; cat: string;
brand: string; brand: string;
specs: string[]; specs: string[];
@@ -52,7 +53,7 @@ interface EditItemRow {
interface EditTsCard { interface EditTsCard {
key: string; key: string;
domId: string; domId: string; // "" یا "new-ts-..." برای کارت جدید
milestones: Milestone[]; milestones: Milestone[];
} }
@@ -61,6 +62,7 @@ export interface EditModalProps {
onClose: () => void; onClose: () => void;
} }
// مودال ویرایش کامل پیشنهاد — بهینه‌شده برای صفحات موبایل
export function EditModal({ quoteId, onClose }: EditModalProps) { export function EditModal({ quoteId, onClose }: EditModalProps) {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -82,11 +84,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
}, [q]); }, [q]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
const [supplierName, setSupplierName] = useState(""); const [supplierName, setSupplierName] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]); const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]); const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]); const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null); const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -112,6 +114,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} }
}; };
// گزینه‌های مشخصه
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]); const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -132,6 +135,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials, itemRows, globalSpecs]); }, [materials, itemRows, globalSpecs]);
// گزینه‌های پروژه
const projectOptions = useMemo(() => { const projectOptions = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name)); projects.forEach((p) => p.name && set.add(p.name));
@@ -146,7 +150,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!q) return; if (!q) return;
const sup = supplierById(suppliers, q.supplier_id); const sup = supplierById(suppliers, q.supplier_id);
setSupplierName(sup.name); setSupplierName(sup.name);
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []); // حفظ تمام پروژه‌های کاندیدا: اتحاد پروژه‌های سطح پیشنهاد و پروژه‌های کاندیدای هر قلم
const candidateSet = new Set<string>();
(q.projects || []).forEach((p) => p && candidateSet.add(p));
if (q.project) candidateSet.add(q.project);
q.line_items.forEach((li) => {
(li.projects || []).forEach((p) => {
const n = typeof p === "string" ? p : p.name;
if (n) candidateSet.add(n);
});
});
setSelectedProjects([...candidateSet]);
setNotes(q.notes || ""); setNotes(q.notes || "");
setItemRows( setItemRows(
q.line_items.map((li) => { q.line_items.map((li) => {
@@ -154,7 +168,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return { return {
key: li.id, key: li.id,
origLi: li, origLi: li,
cat: m.category || "", projects: li.projects && li.projects.length ? [...li.projects] : [],
cat: m.name || "", // نام خود متریال — نه دسته/برند
brand: m.brand || "", brand: m.brand || "",
specs: materialSpecs(m), specs: materialSpecs(m),
qty: li.quantity, qty: li.quantity,
@@ -183,6 +198,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
setReady(true); setReady(true);
}, [q, suppliers, materials]); }, [q, suppliers, materials]);
// بارگذاری فاکتورها و پیش‌فاکتورها
useEffect(() => { useEffect(() => {
if (!quoteId) return; if (!quoteId) return;
let alive = true; let alive = true;
@@ -190,6 +206,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice") .list(quoteId, "invoice")
.then((list) => alive && setInvoices(list)) .then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([])); .catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => { return () => {
alive = false; alive = false;
}; };
@@ -218,7 +238,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "", unit: "",
price: null, price: null,
total: null, total: null,
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [], tsDomIds: [],
acceptedTsId: null, acceptedTsId: null,
rejected: false, rejected: false,
restore: true, restore: true,
@@ -249,6 +269,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const next: Quote = JSON.parse(JSON.stringify(q)); const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده
if (supplierName.trim()) { if (supplierName.trim()) {
const norm = normalize(supplierName); const norm = normalize(supplierName);
const exist = suppliers.find((s) => normalize(s.name) === norm); const exist = suppliers.find((s) => normalize(s.name) === norm);
@@ -266,6 +287,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
// termsheetها
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
tsCards.forEach((card, i) => { tsCards.forEach((card, i) => {
@@ -282,6 +304,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
}); });
next.termsheets = newTermsheets; next.termsheets = newTermsheets;
// اقلام
const newItems: LineItem[] = []; const newItems: LineItem[] = [];
const materialsCopy = [...materials]; const materialsCopy = [...materials];
for (const row of itemRows) { for (const row of itemRows) {
@@ -296,7 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
let material = materialsCopy.find( let material = materialsCopy.find(
(m) => (m) =>
normalize(m.category) === normalize(cat) && normalize(m.name) === normalize(cat) &&
normalize(m.brand) === normalize(brand) && normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs), specsKey(m.specs) === specsKey(specs),
); );
@@ -343,8 +366,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
acceptedTsId = null; acceptedTsId = null;
} }
const candProjs =
row.projects && row.projects.length
? row.projects
: row.origLi?.projects && row.origLi.projects.length
? row.origLi.projects
: selectedProjects.map((pname) => ({ id: "", name: pname }));
newItems.push({ newItems.push({
id: "li-" + (newItems.length + 1), id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
material_id: materialId, material_id: materialId,
quantity: qty, quantity: qty,
unit, unit,
@@ -354,7 +384,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
accepted_ts_id: acceptedTsId, accepted_ts_id: acceptedTsId,
rejected, rejected,
arrived_date: row.origLi?.arrived_date ?? null, arrived_date: row.origLi?.arrived_date ?? null,
projects: row.origLi?.projects ?? [], projects: candProjs,
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null), target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null), target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
}); });
@@ -370,6 +400,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} }
}); });
// تاریخ‌ها و وضعیت
next.quote_date = quoteDate || q.quote_date; next.quote_date = quoteDate || q.quote_date;
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : ""; next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
next.accepted_date = acceptedDate; next.accepted_date = acceptedDate;
@@ -409,103 +440,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return ( return (
<> <>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl"> <DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
{/* هدر سفید و تمیز */} <DialogHeader>
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right"> <DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div>
</DialogHeader> </DialogHeader>
{hasReceipts && ( {hasReceipts && (
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0"> <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 text-amber-600" /> <AlertCircle className="h-4 w-4 shrink-0" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span> <span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div> </div>
)} )}
{/* تب‌بار سفید و استاندارد */} {/* اطلاعات بالایی — تک‌ستونه در موبایل، ۲ ستونه در دسکتاپ */}
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
<button
type="button"
onClick={() => setActiveTab("items")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "items"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Package className="h-3.5 w-3.5" />
<span>اقلام و سفارش</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(itemRows.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("terms")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "terms"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<CreditCard className="h-3.5 w-3.5" />
<span>شرایط پرداخت</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(tsCards.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("dates")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "dates"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Calendar className="h-3.5 w-3.5" />
<span>وضعیت و اسناد</span>
{invoices.length > 0 && (
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(invoices.length)}
</span>
)}
</button>
</div>
{/* محتوای مودال — پس‌زمینه کاملاً سفید */}
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
{/* تب ۱: اقلام و مشخصات */}
{activeTab === "items" && (
<div className="space-y-4">
{/* کارت تأمین‌کننده و پروژه */}
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1.5">
<label className="text-xs font-semibold text-gray-600">تأمین‌کننده</label> <label className="text-xs font-semibold text-muted">تأمین‌کننده</label>
<div className="w-full min-w-0">
<Combobox <Combobox
value={supplierName} value={supplierName}
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)} onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
@@ -514,11 +465,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
addSuffix="به عنوان تأمین‌کنندهٔ جدید" addSuffix="به عنوان تأمین‌کنندهٔ جدید"
placeholder="نام تأمین‌کننده" placeholder="نام تأمین‌کننده"
onInputChange={(v) => setSupplierName(v)} onInputChange={(v) => setSupplierName(v)}
openOnFocus={false}
/> />
</div> </div>
</div>
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1.5">
<label className="text-xs font-semibold text-gray-600">پروژه</label> <label className="text-xs font-semibold text-muted">پروژه</label>
<div className="w-full min-w-0">
<MultiSelectCombobox <MultiSelectCombobox
values={selectedProjects} values={selectedProjects}
onChange={setSelectedProjects} onChange={setSelectedProjects}
@@ -528,82 +482,67 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
<div className="flex min-w-0 flex-col gap-1"> <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-gray-600">یادداشت</label> <label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea <textarea
rows={2} rows={2}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30" 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} value={notes}
placeholder="یادداشت‌های داخلی این معامله..."
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* لیست اقلام */} {/* اقلام */}
<div className="space-y-3"> <div className="border-t border-line pt-3">
<div className="flex items-center justify-between"> <div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span> <div className="flex flex-col gap-3">
</div> {itemRows.map((row) => (
{itemRows.map((row, idx) => (
<div <div
key={row.key} key={row.key}
className={cn( className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`}
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
)}
> >
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100"> <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4">
<div className="flex items-center gap-1.5 min-w-0"> {/* متریال — تمام‌عرض در موبایل */}
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600"> <div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
{faNum(idx + 1)} <label className="text-[11px] font-semibold text-muted">متریال</label>
</span>
<span className="text-xs font-bold text-gray-900 truncate">
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
</span>
</div>
{!hasReceipts && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" className="h-9 w-full rounded-md border border-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="نام متریال" placeholder="متریال"
value={row.cat} value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })} onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* برند */}
<label className="text-[11px] font-semibold text-gray-600">برند</label> <div className="col-span-1 flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-semibold text-muted">برند</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" className="h-9 w-full rounded-md border border-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="برند" placeholder="برند"
value={row.brand} value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })} onChange={(e) => patchItem(row.key, { brand: e.target.value })}
/> />
</div> </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>
<div className="grid grid-cols-2 gap-2.5"> {/* مشخصه — تمام‌عرض در موبایل */}
<div className="flex flex-col gap-1"> <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-gray-600">مشخصه</label> <label className="text-[11px] font-semibold text-muted">مشخصه</label>
<div className="w-full min-w-0">
<MultiSelectCombobox <MultiSelectCombobox
disabled={hasReceipts} disabled={hasReceipts}
values={row.specs} values={row.specs}
@@ -612,23 +551,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
placeholder="مشخصه" placeholder="مشخصه"
/> />
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد سنجش"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div> </div>
{/* جعبه ۳ عددی مقادیر */} {/* تعداد */}
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200"> <div className="col-span-1 flex min-w-0 flex-col gap-1">
<div className="flex flex-col gap-1"> <label className="text-[11px] font-semibold text-muted">تعداد</label>
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.qty} value={row.qty}
@@ -642,8 +569,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* قیمت واحد */}
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label> <div className="col-span-1 flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.price} value={row.price}
@@ -653,13 +581,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total, total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
}); });
}} }}
placeholder="واحد" placeholder="قیمت واحد"
maxDecimals={2} maxDecimals={2}
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* قیمت کل */}
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label> <div className="col-span-1 flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.total} value={row.total}
@@ -670,45 +599,48 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price, t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
}); });
}} }}
placeholder="جمع" placeholder="قیمت کل"
/> />
</div> </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>
{/* شرایط پرداخت برای این قلم */} {/* تخصیص termsheet */}
<div className="pt-2 border-t border-gray-100"> <div className="mt-2.5 border-t border-line/60 pt-2">
{row.rejected ? ( {row.rejected ? (
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer"> <label className="ts-cb-label e-restore">
<input <input
type="checkbox" type="checkbox"
className="accent-blue-600 h-4 w-4 rounded"
checked={row.restore} checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })} onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/> />
بازگرداندن این قلم به معامله بازگرداندن به معامله
</label> </label>
) : ( ) : (
<div className="flex flex-col gap-1.5"> <>
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span> <div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{tsCards.map((card, cIdx) => { {tsCards.map((card, idx) => (
const isSelected = row.tsDomIds.includes(card.domId || card.key); <label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
return (
<label
key={card.key}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
isSelected
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
hasReceipts && "cursor-not-allowed opacity-60",
)}
>
<input <input
type="checkbox" type="checkbox"
disabled={hasReceipts} disabled={hasReceipts}
className="accent-blue-600 h-3.5 w-3.5 rounded" className="me-1 accent-accent disabled:cursor-not-allowed"
checked={isSelected} checked={row.tsDomIds.includes(card.domId || card.key)}
onChange={(e) => { onChange={(e) => {
const next = new Set(row.tsDomIds); const next = new Set(row.tsDomIds);
const domId = card.domId || card.key; const domId = card.domId || card.key;
@@ -717,170 +649,73 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
patchItem(row.key, { tsDomIds: [...next] }); patchItem(row.key, { tsDomIds: [...next] });
}} }}
/> />
<span>{tsLabel(card, cIdx)}</span> {tsLabel(card, idx)}
</label> </label>
); ))}
})}
</div>
</div> </div>
</>
)} )}
</div> </div>
</div> </div>
</div>
))} ))}
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
onClick={addItemRow}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن قلم جدید</span>
</Button>
)}
</div> </div>
</div>
)}
{/* تب ۲: شرایط پرداخت */}
{activeTab === "terms" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between">
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
{!hasReceipts && ( {!hasReceipts && (
<Button <Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
type="button" + افزودن قلم
variant="line"
size="sm"
className="gap-1 text-xs text-gray-700"
onClick={addTsCard}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن گزینه جدید</span>
</Button> </Button>
)} )}
</div> </div>
<div className="flex flex-col gap-3"> {/* 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) => ( {tsCards.map((card, idx) => (
<div <div key={card.key} className="variant-card">
key={card.key} <div className="variant-head">
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs" <span className="variant-num">{tsLabel(card, idx)}</span>
> {!hasReceipts && (
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5"> <button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
<div className="flex items-center gap-1.5"> ×
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
<span className="text-[11px] text-gray-500 font-medium">
({faNum(card.milestones.length)} مرحله)
</span>
</div>
{!hasReceipts && tsCards.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف گزینه"
onClick={() => removeTsCard(card.key)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button> </button>
)} )}
</div> </div>
<div className="mb-1 flex flex-col gap-1.5">
<div className="flex flex-col gap-2 mb-3">
{card.milestones.map((ms, i) => ( {card.milestones.map((ms, i) => (
<MilestoneRow <MilestoneRow
key={i} key={i}
milestone={ms} milestone={ms}
disabled={hasReceipts} disabled={hasReceipts}
onChange={(nextMs) => onChange={(nextMs) =>
updateTs(card.key, { updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
} }
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
/> />
))} ))}
</div> </div>
{!hasReceipts && ( {!hasReceipts && (
<Button <Button
type="button"
variant="line" variant="line"
size="sm" size="sm"
className="w-full sm:w-auto text-xs text-gray-700" className="mt-1"
onClick={() => onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
updateTs(card.key, {
milestones: [...card.milestones, { when_iso: null, amount: null }],
})
}
> >
<Plus className="h-3.5 w-3.5 me-1" /> + مرحله پرداخت
افزودن مرحله پرداخت
</Button> </Button>
)} )}
</div> </div>
))} ))}
</div> </div>
</div> {!hasReceipts && (
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
+ افزودن termsheet
</Button>
)} )}
{/* تب ۳: وضعیت و اسناد */}
{activeTab === "dates" && (
<div className="space-y-3.5">
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
<select
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
value={status}
disabled={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
<div className="text-[11px] text-gray-500">
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
</div>
</div> </div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs"> {/* فاکتورها */}
<div className="text-xs font-bold text-gray-900">تاریخ‌های چرخه خرید</div> <div className="border-t border-line pt-3">
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2"> <div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوست‌ها</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -894,32 +729,87 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)} urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
/> />
</div> </div>
{/* پیش‌فاکتورها — در تمامی مراحل چرخه عمر پیشنهاد قابل افزودن/ویرایش است */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
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>
))}
</select>
{hasReceipts && (
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
</div> </div>
)} )}
</div> </div>
</div>
</div>
{/* فوتر سفید، محکم و چسبان */} {/* فوتر و دکمه‌های اقدام */}
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2"> <DialogFooter className="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" ? ( {q.status === "offered" ? (
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="gap-1 px-2.5 sm:px-3 text-xs shrink-0" className="w-full gap-1.5 sm:w-auto"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">حذف</span> <span>حذف پیشنهاد</span>
</Button> </Button>
) : ( ) : (
<div /> <div />
)} )}
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
<div className="flex items-center gap-2"> <Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
انصراف انصراف
</Button> </Button>
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs"> <Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
ذخیره تغییرات ذخیره تغییرات
</Button> </Button>
</div> </div>
@@ -927,20 +817,19 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* مودال تایید حذف سفید */} {/* مودال تأیید حذف */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}> <Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900"> <DialogContent className="z-[60] max-w-md p-4 sm:p-6">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle> <DialogTitle>حذف پیشنهاد خرید</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2 text-xs sm:text-sm text-gray-600"> <div className="py-2 text-sm text-muted">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است. آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</div> </div>
<DialogFooter className="flex items-center justify-end gap-2 pt-2"> <DialogFooter className="flex items-center justify-end gap-2 pt-2">
<Button <Button
type="button" type="button"
variant="slate" variant="slate"
size="sm"
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
disabled={deleting} disabled={deleting}
> >
@@ -949,7 +838,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
size="sm"
onClick={() => void handleDelete()} onClick={() => void handleDelete()}
disabled={deleting} disabled={deleting}
> >
+11 -43
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
export interface Filters { export interface Filters {
category: string; category: string;
@@ -9,33 +10,8 @@ export interface Filters {
search: string; search: string;
} }
// گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه // لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری
const STATUS_OPTIONS = [ const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
{ id: "offered", label: "پیشنهاد باز" },
{ id: "accepted", label: "پذیرفته‌شده" },
{ id: "arrived", label: "تحویل‌شده" },
];
const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز",
accepted: "پذیرفته‌شده",
arrived: "تحویل‌شده",
};
const STATUS_TO_KEY: Record<string, string> = {
"پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تحویل": "arrived",
offered: "offered",
accepted: "accepted",
arrived: "arrived",
};
export interface FilterToolbarProps { export interface FilterToolbarProps {
filters: Filters; filters: Filters;
@@ -64,14 +40,6 @@ export function FilterToolbar({
onChange({ ...filters, category: "", supplier: "", status: "" }); onChange({ ...filters, category: "", supplier: "", status: "" });
}; };
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
const handleStatusChange = (val: string) => {
const key = STATUS_TO_KEY[val] || val;
set({ status: key });
};
return ( return (
<> <>
{/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */} {/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */}
@@ -180,12 +148,12 @@ export function FilterToolbar({
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label> <label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
<Combobox <Combobox
value={statusDisplayValue} value={filters.status}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)} onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={STATUS_OPTIONS} items={STATUS_ITEMS}
allowAdd={false} allowAdd={false}
placeholder="همه وضعیت‌ها" placeholder="همه وضعیت‌ها"
onInputChange={handleStatusChange} onInputChange={(v) => set({ status: v })}
/> />
</div> </div>
</div> </div>
@@ -229,12 +197,12 @@ export function FilterToolbar({
<div className="field min-w-[150px] flex-1"> <div className="field min-w-[150px] flex-1">
<label>وضعیت</label> <label>وضعیت</label>
<Combobox <Combobox
value={statusDisplayValue} value={filters.status}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)} onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={STATUS_OPTIONS} items={STATUS_ITEMS}
allowAdd={false} allowAdd={false}
placeholder="همه" placeholder="همه"
onInputChange={handleStatusChange} onInputChange={(v) => set({ status: v })}
/> />
</div> </div>
<div className="field min-w-[200px] flex-1"> <div className="field min-w-[200px] flex-1">
+24 -38
View File
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
import type { Quote, Store } from "../../lib/types"; import type { Quote, Store } from "../../lib/types";
import { import {
isExpired, isExpired,
itemsSummary,
materialById, materialById,
materialLabel, materialLabel,
projectCell, projectCell,
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const pc = priceCell(total); const pc = priceCell(total);
const ts = termsheetSummary(store, q); const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q); const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون / دکمه پذیرش // ستون / دکمه پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{sup.name.slice(0, 1)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<Link <Link
to={`/suppliers/${q.supplier_id}`} to={`/suppliers/${q.supplier_id}`}
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]" className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
> >
{sup.name} {sup.name}
</Link> </Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<span
className={cn(
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
q.is_official
? "bg-green-bg text-green"
: "bg-slate-bg text-muted",
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span>
</div>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>} {sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5"> <div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
@@ -188,47 +200,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</InfoCell> </InfoCell>
{/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */} {/* ۲. اقلام — نمایش عمودی چندخطی */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1"> <InfoCell label="اقلام" className="col-span-2 md:col-span-1">
{summary.count > 1 ? ( <div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
<div className="flex flex-col gap-1.5"> {activeItems.length > 0 ? (
<span className="font-bold text-xs text-ink sm:text-sm"> activeItems.map((li) => {
{fmtNum(summary.count)} قلم کالا
</span>
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
{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); const m = materialById(store.materials, li.material_id);
return ( return (
<div <span key={li.id} className="break-words">
key={li.id} {materialLabel(m)}
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
>
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
• {materialLabel(m)}
</span> </span>
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
{fmtNum(li.quantity)}{" "}
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
</span>
</div>
); );
})} })
{(q.line_items?.length || 0) > 3 && (
<span className="pt-0.5 text-[10px] text-muted">
و {(q.line_items?.length || 0) - 3} قلم دیگر...
</span>
)}
</div>
</div>
) : ( ) : (
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]"> <span>—</span>
<span className="break-words">{summary.text}</span>
</div>
)} )}
</div>
{unitWarns.map((w) => ( {unitWarns.map((w) => (
<Link <Link
@@ -243,7 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))} ))}
</InfoCell> </InfoCell>
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1"> <InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? ( {pc ? (
<div> <div>
+4 -28
View File
@@ -7,7 +7,6 @@ import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
@@ -16,6 +15,7 @@ import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک)
export function CategoryPage() { export function CategoryPage() {
useStoreData(); useStoreData();
const { category: catParam = "" } = useParams(); const { category: catParam = "" } = useParams();
@@ -36,29 +36,12 @@ export function CategoryPage() {
contact: "", contact: "",
}); });
// همه‌ی تأمین‌کننده‌ها برای انتخاب سریع // همه‌ی تأمین‌کننده‌ها برای انتخاب سریع (task: کومبوکس کش‌شده)
const supplierOptions = useMemo( const supplierOptions = useMemo(
() => suppliers.map((s) => ({ id: s.id, name: s.name })), () => suppliers.map((s) => ({ id: s.id, name: s.name })),
[suppliers], [suppliers],
); );
// محاسبه میانگین زمان تحویل اختصاصی هر تأمین‌کننده بر اساس سفارش‌های تحویل‌شده
const supLeadTimes = useMemo(() => {
const map = new Map<string, number | null>();
suppliers.forEach((s) => {
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
map.set(s.id, avg);
});
return map;
}, [quotes, suppliers]);
if (!cat) return <Navigate to="/suppliers" replace />; if (!cat) return <Navigate to="/suppliers" replace />;
const sups = suppliersOfCategory(cat, cat, suppliers); const sups = suppliersOfCategory(cat, cat, suppliers);
@@ -86,6 +69,7 @@ export function CategoryPage() {
toast.error("نام تأمین‌کننده را وارد کنید."); toast.error("نام تأمین‌کننده را وارد کنید.");
return; return;
} }
// تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده)
const s: Supplier = { const s: Supplier = {
id: nextId("s", suppliers), id: nextId("s", suppliers),
topic: cat, topic: cat,
@@ -117,15 +101,7 @@ export function CategoryPage() {
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sups.map((s) => ( {sups.map((s) => (
<SupplierCard <SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
key={s.id}
s={s}
topic={cat}
deletable
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
/>
))} ))}
</div> </div>
)} )}
+5 -19
View File
@@ -1,9 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus, Timer } from "lucide-react"; import { MapPin, Phone, User, Plus } from "lucide-react";
import type { Material, Supplier } from "../../lib/types"; import type { Material, Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters";
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
const out = new Set<string>(); const out = new Set<string>();
materials materials
@@ -21,45 +19,33 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
); );
} }
// کارت تأمین‌کننده همراه با برچسب میانگین زمان تحویل // کارت تأمین‌کننده
export function SupplierCard({ export function SupplierCard({
s, s,
topic, topic,
deletable, deletable,
onDelete, onDelete,
materials = [], materials = [],
avgLeadTime,
}: { }: {
s: Supplier; s: Supplier;
topic: string; topic: string;
deletable?: boolean; deletable?: boolean;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
// متریال‌های خریداری‌شده از این تأمین‌کننده (به‌جای دسته‌بندی)
materials?: string[]; materials?: string[];
avgLeadTime?: number | null;
}) { }) {
return ( return (
<div className="sup-card"> <div className="sup-card">
<div className="sc-head flex items-start justify-between gap-2"> <div className="sc-head">
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug"> <Link to={`/suppliers/${s.id}`} className="sc-name-link">
{s.name} {s.name}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0">
{avgLeadTime != null && (
<span
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
title="میانگین زمان تحویل سفارش‌ها"
>
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
<span>{fmtNum(avgLeadTime)} روز</span>
</span>
)}
{deletable && onDelete && ( {deletable && onDelete && (
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}> <button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}>
× ×
</button> </button>
)} )}
</div> </div>
</div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"} <MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
+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>
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span> <span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
</button> </button>
@@ -519,7 +519,7 @@ export function UnitsPage() {
)} )}
onClick={() => setMaterialId(v.id)} onClick={() => setMaterialId(v.id)}
> >
<span className="truncate">{v.label}</span> <span className="whitespace-normal break-words text-start">{v.label}</span>
<span className="ut-ref">{v.refUnit || "—"}</span> <span className="ut-ref">{v.refUnit || "—"}</span>
</button> </button>
))} ))}
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
)) ))
)} )}
</div> </div>
<div className={cn("field max-w-md", invalid && "invalid")}> <div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
<label>یا نام تأمین‌کننده را تایپ و انتخاب کنید</label> <label>یا نام تأمین‌کننده را تایپ و انتخاب کنید</label>
<Combobox <Combobox
value={supplierName} value={supplierName}
+6 -5
View File
@@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category
return base || fallback; return base || fallback;
} }
// مقادیر مجزای category/brand/spec داخل تاپیک // مقادیر مجزای category/brand/spec در کاتالوگ سراسری
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
topic: string, _topic: string,
item: ItemDraft, item: ItemDraft,
field: "cat" | "brand" | "spec", field: "cat" | "brand" | "spec",
allItems: ItemDraft[] = [], allItems: ItemDraft[] = [],
apiBrands: string[] = [], apiBrands: string[] = [],
apiSpecs: string[] = [], apiSpecs: string[] = [],
): string[] { ): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic)); // متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials;
const cat = item.cat.trim(); const cat = item.cat.trim();
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => { const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
@@ -92,7 +93,7 @@ export function materialOptions(
}; };
if (field === "cat") { 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)); fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa")); return [...base].sort((a, b) => a.localeCompare(b, "fa"));
} }
@@ -221,7 +222,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<span>قیمت کل</span> <span>قیمت کل</span>
</div> </div>
<div className="flex flex-col gap-2"> <div data-field="line_items" className="flex flex-col gap-2">
{items.map((it) => ( {items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row items-center"> <div key={it.tempId} className="item-row wizard-item-row items-center">
<Combobox <Combobox
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
</h3> </h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلم‌ها را پوشش می‌دهد — یک termsheet می‌تواند چند قلم را بگیرد، و یک قلم هم می‌تواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} /> <div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلم‌ها را پوشش می‌دهد — یک termsheet می‌تواند چند قلم را بگیرد، و یک قلم هم می‌تواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
<div className="flex flex-col gap-3"> <div data-field="termsheets" className="flex flex-col gap-3">
{cards.map((card, idx) => ( {cards.map((card, idx) => (
<TermsheetCard <TermsheetCard
key={card.key} key={card.key}
+1 -1
View File
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
}; };
return ( return (
<div> <div data-field="project">
<h3 className="mb-1 text-[15px] font-bold"> <h3 className="mb-1 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span> <span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
پروژه‌ها پروژه‌ها
+4 -4
View File
@@ -57,20 +57,20 @@ export function Step5Review({
<div className="grid max-w-2xl grid-cols-12 gap-3"> <div className="grid max-w-2xl grid-cols-12 gap-3">
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */} {/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full"> <div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
<label>تاریخ پیشنهاد</label> <label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime /> <JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
<div className="hint">پیش‌فرض امروز</div> <div className="hint">پیش‌فرض امروز</div>
</div> </div>
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */} {/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full"> <div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
<label>تاریخ انقضای پیشنهاد</label> <label>تاریخ انقضای پیشنهاد</label>
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" /> <JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div> <div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
</div> </div>
<div className="field col-span-12"> <div className="field col-span-12" data-field="notes">
<label>یادداشت (اختیاری)</label> <label>یادداشت (اختیاری)</label>
<textarea <textarea
rows={2} rows={2}
@@ -81,7 +81,7 @@ export function Step5Review({
/> />
</div> </div>
<div className="field col-span-12"> <div className="field col-span-12" data-field="proforma">
<label>پیش‌فاکتور (اختیاری)</label> <label>پیش‌فاکتور (اختیاری)</label>
{proforma ? ( {proforma ? (
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */ /* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
+77 -31
View File
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react"; import { ChevronRight, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types"; import type { LineItem, Quote } from "../../lib/types";
import { api, nextId } from "../../lib/api"; import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
import { import {
useAddMaterial, useAddMaterial,
useAddProject, useAddProject,
@@ -125,16 +125,8 @@ export function WizardPage() {
goToStep(currentStep + 1); goToStep(currentStep + 1);
}; };
const rememberMaterial = (rec: MaterialRec) => { const rememberMaterial = (_rec: MaterialRec) => {
void addMaterial.mutateAsync({ // رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
id: nextId("m", materials),
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
topic: rec.topic,
category: rec.category,
brand: rec.brand,
specs: rec.specs,
base_unit: "",
});
}; };
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ---------- // ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ----------
@@ -193,19 +185,72 @@ export function WizardPage() {
// ---------- ذخیره ---------- // ---------- ذخیره ----------
const doSave = async () => { const doSave = async () => {
const sectionError = (step: number, title: string, err: unknown) => { const resolveErrorTarget = (err: unknown): { step: number; field?: string } => {
let field = "";
let msg = "";
if (err instanceof ApiError) {
field = err.field || "";
msg = err.message || "";
} else if (err instanceof Error) {
msg = err.message || "";
if ("field" in err && typeof (err as any).field === "string") {
field = (err as any).field;
}
}
const combined = `${field} ${msg}`.toLowerCase();
// ۱. تأمین‌کننده
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
// ۲. اقلام / متریال / واحد / تعداد / قیمت
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
return { step: 2, field: field || "line_items" };
}
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
return { step: 3, field: field || "termsheets" };
}
// ۴. پروژه
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
// ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" };
if (/notes|یادداشت/i.test(combined)) return { step: 5, field: "notes" };
return { step: 5, field };
};
const sectionError = (stepOrTarget: number | { step: number; field?: string }, title: string, err: unknown) => {
const target = typeof stepOrTarget === "number" ? { step: stepOrTarget } : stepOrTarget;
const msg = err instanceof Error ? err.message : "خطای ناشناخته"; const msg = err instanceof Error ? err.message : "خطای ناشناخته";
toast.error(`${title}: ${msg}`, { toast.error(`${title}: ${msg}`, {
action: { label: "رفع خطا", onClick: () => goToStep(step) }, duration: 8000,
action: {
label: "رفع خطا",
onClick: () => {
goToStep(target.step);
setTimeout(() => {
const targetAttr = target.field ? `[data-field="${target.field}"]` : "";
const el = (targetAttr ? document.querySelector(targetAttr) : null) ||
document.querySelector(`[data-step="${target.step}"]`);
if (el instanceof HTMLElement) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
const input = el.querySelector("input, textarea, select") as HTMLElement | null;
if (input) input.focus();
else el.focus?.();
el.classList.add("ring-2", "ring-red-500", "rounded-xl");
setTimeout(() => el.classList.remove("ring-2", "ring-red-500", "rounded-xl"), 3500);
}
}, 150);
},
},
}); });
}; };
const guessStep = (msg: string): number => {
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
if (/پروژه|project/i.test(msg)) return 4;
return 5;
};
const name = supplierName.trim(); const name = supplierName.trim();
let supplierId = ""; let supplierId = "";
@@ -244,10 +289,11 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید"; const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
let material = materials.find((m) => normalize(m.name) === normalize(wantName)); let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) { if (!material) {
material = materials.find( material = materials.find(
(m) => (m) =>
looksLikeUuid(m.id) &&
normalize(m.category) === normalize(cat) && normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) && normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs), specsKey(m.specs) === specsKey(specs),
@@ -257,11 +303,11 @@ export function WizardPage() {
// تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند. // تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند.
if (!material && cat) { if (!material && cat) {
material = materials.find( 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; let materialId: string;
if (material) { if (material && looksLikeUuid(material.id)) {
materialId = material.id; materialId = material.id;
} else { } else {
try { try {
@@ -272,7 +318,7 @@ export function WizardPage() {
category: cat, category: cat,
brand, brand,
specs, specs,
base_unit: unit, base_unit: unit || "عدد",
}); });
materialId = created.id; materialId = created.id;
} catch (err) { } catch (err) {
@@ -361,8 +407,8 @@ export function WizardPage() {
toast.success("پیشنهاد ذخیره شد."); toast.success("پیشنهاد ذخیره شد.");
navigate("/"); navigate("/");
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد"; const target = resolveErrorTarget(e);
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e); sectionError(target, "خطا در ثبت پیشنهاد", e);
} }
}; };
@@ -392,7 +438,7 @@ export function WizardPage() {
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} /> <Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{currentStep === 1 && ( {currentStep === 1 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step1TopicSupplier <Step1TopicSupplier
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
onTopicSelect={(t) => { onTopicSelect={(t) => {
@@ -416,12 +462,12 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} /> <Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div> </div>
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step3Termsheets <Step3Termsheets
cards={cards} cards={cards}
onCardsChange={setCards} onCardsChange={setCards}
@@ -432,7 +478,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step4Projects <Step4Projects
items={items} items={items}
projects={projectNames} projects={projectNames}
@@ -446,7 +492,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 5 && ( {currentStep === 5 && (
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step5Review <Step5Review
quoteDate={quoteDate} quoteDate={quoteDate}
onQuoteDate={setQuoteDate} onQuoteDate={setQuoteDate}
+2 -22
View File
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id) CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
); );
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ---------- -- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- category_id is NULLABLE and DERIVED automatically from the first linked -- Materials are global, category-free entities. name is GLOBALLY
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
-- unique (uq_material_name). topic is a legacy display column no longer -- unique (uq_material_name). topic is a legacy display column no longer
-- written by the app. -- written by the app.
CREATE TABLE materials ( CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL, name TEXT NOT NULL,
topic TEXT, -- LEGACY display column topic TEXT, -- LEGACY display column
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
@@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
-- AUTOMATION TRIGGERS (v2.5 + v2.6) -- AUTOMATION TRIGGERS (v2.5 + v2.6)
-- ============================================================================ -- ============================================================================
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
-- Idempotent: only touches rows where category_id IS NULL.
CREATE OR REPLACE FUNCTION fn_derive_material_category()
RETURNS TRIGGER AS $$
BEGIN
UPDATE materials
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
updated_at = now()
WHERE id = NEW.material_id
AND category_id IS NULL;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item -- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow -- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
-- inserts decisions directly as 'chosen'). -- inserts decisions directly as 'chosen').