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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted.

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

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

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

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

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

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