Author SHA1 Message Date
whqreza d85ede7d11 feat(inventory): display all warehouse items on selection and track withdrawal recipient
- Show all available inventory items immediately when selecting a warehouse in withdrawal form
- Add vertical scrolling container with max height to prevent layout overflow for large item lists
- Add Material column to the inventory rows table for clear item identification
- Add required 'delivered_to' recipient field in withdrawal form with frontend validation
- Track delivered_to in database, models, schemas, and API
- Display recipient badge on withdrawal cards in daily control page
- Group daily control withdrawals by inventory ID and recipient
2026-09-28 22:54:02 +03:30
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

This commit sets the tree back to exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
2026-09-26 17:41:07 +00:00
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
Behrad 49dd159fb1 feat: improve line items layout, number inputs and supplier click-to-call 2026-09-22 23:06:17 +03:30
Behrad 972b1b49cb fix(dashboard): restore expand toggle on mobile quote cards and add explicit expand action bar 2026-09-21 19:57:15 +03:30
Behrad b176f0f5ff fix(wizard): optimize stepper and line items layout for mobile 2026-09-21 19:39:12 +03:30
Behrad 9386d4846f feat: add lead time badge, refine mobile supplier click, and integrate termsheet cards in edit modal 2026-09-21 19:26:52 +03:30
ZCode d69ec7f7a9 fix(analytics): the Track API payload field is 'name', not 'event'
Second half of the envelope fix: ITrackPayload is {name, properties, profileId} — sending
'event' is rejected with 400 "payload/name Invalid input: expected string", which the
module swallows by design. Verified against packages/validation/src/track.validation.ts
and the SDK's track(name, properties) signature.
2026-09-21 09:16:21 +00:00
ZCode 34290b9371 fix(analytics): declare the script build arg and send the Track API's real envelope
Two blockers found while wiring OpenPanel for real:

- frontend/Dockerfile declared ARG for VITE_OPENPANEL_URL/CLIENT_ID/SENTRY_DSN but not
  VITE_OPENPANEL_SCRIPT_URL, so passing it as a build arg was silently ignored and the
  loader compiled out (import.meta.env value undefined).
- backend/app/services/analytics.py posted a flat {type, event, properties} body; the
  Track API expects {type, payload:{event, properties, profileId}} (verified against the
  SDK source in packages/sdks/sdk). The API answered 400 and the module swallows every
  error by design, so all server-side events were being dropped without a trace.

Verified after deploy: the bundle carries the client id + op1.js URL + the queueing shim,
op1.js serves 200, and a track event posted with the real envelope lands in OpenPanel.
2026-09-21 09:15:02 +00:00
ZCode cff35e3660 fix(analytics): actually load the OpenPanel browser script
analytics.ts called window.op?.(…) but nothing ever defined that global — no script tag
in index.html and @openpanel/web was never imported — so every call was a silent no-op
and product analytics would have stayed empty no matter how the build args were set.

Now, when VITE_OPENPANEL_SCRIPT_URL is configured, the loader installs OpenPanel's
documented queueing shim (window.op becomes a queue) and appends op1.js async; the
existing op("init"/"identify"/"track") calls are queued and drained when it loads, so
ordering cannot break them. Without the URL the behaviour is unchanged (no-op), which
keeps local dev and air-gapped builds silent.

Build args: VITE_OPENPANEL_URL (API base, e.g. https://stats.<host>/api),
VITE_OPENPANEL_CLIENT_ID, VITE_OPENPANEL_SCRIPT_URL (e.g. https://stats.<host>/op1.js).
The client *secret* is deliberately not a frontend arg — it belongs to the API only.
2026-09-21 09:13:27 +00:00
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00
ZCode 2fd72fecf7 feat(dashboard): drop the category filter
The category filter is not used: remove it end to end — the toolbar's category
combobox (desktop + mobile drawer), its option list, the active-filter count and
the filterQuotes() clause it drove, plus the now-unused `category` field in the
Filters type. Supplier and status filters are unchanged.

The backend's own quote filtering by category is untouched (it filters on the
quote's supplier category when ?category_id= is passed), so the API keeps its
contract; only the dashboard UI stops offering the filter.
2026-09-20 19:25:19 +00:00
43 changed files with 883 additions and 1481 deletions
+20 -19
View File
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
_DASHBOARD_DDL = [ _DASHBOARD_DDL = [
# One reference unit per category (units.html «واحد مرجع هر دسته») # One reference unit per category (units.html «واحد مرجع هر دسته»)
"""
CREATE TABLE IF NOT EXISTS category_reference_units (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
ref_unit TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit. # Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct). # UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
""" """
CREATE TABLE IF NOT EXISTS unit_conversions (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
brand TEXT,
from_unit TEXT NOT NULL,
to_unit TEXT NOT NULL,
factor NUMERIC(18,6) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit) ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
""", """,
@@ -266,6 +247,7 @@ _V26_DDL = [
physical_qty NUMERIC(18,6), physical_qty NUMERIC(18,6),
transfer_pair_id UUID, transfer_pair_id UUID,
performed_by TEXT, performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(), transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT, note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -277,6 +259,9 @@ _V26_DDL = [
""" """
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date) CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
""", """,
"""
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
""",
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it. # suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
""" """
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
@@ -413,6 +398,22 @@ async def ensure_v26(engine: AsyncEngine) -> None:
""") """)
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
These three tables backed features that were never populated (0 rows on every
volume): per-category reference units and conversion factors (the
per-reference-unit price line) and the category -> attribute mapping. Dropping
them is idempotent and loses no data. material_categories itself stays — every
supplier carries one as its label.
"""
async with engine.begin() as conn:
await conn.execute(text(
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
))
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
async def ensure_audit_table(engine: AsyncEngine) -> None: async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" """v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """ ddl = """
+2
View File
@@ -27,6 +27,7 @@ from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import ( from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
ensure_quote_projects, ensure_quote_projects,
@@ -105,6 +106,7 @@ async def lifespan(_app: FastAPI):
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try: try:
yield yield
+1
View File
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row. # NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True)) transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder) performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
delivered_to: Mapped[str | None] = mapped_column(Text)
transaction_date: Mapped[datetime] = mapped_column( transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
-6
View File
@@ -4,8 +4,6 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
category_attributes,
category_ref_units,
files, files,
inventory, inventory,
item_decisions, item_decisions,
@@ -24,7 +22,6 @@ from app.routers import (
supplier_materials, supplier_materials,
suppliers, suppliers,
termsheets, termsheets,
unit_conversions,
units, units,
users, users,
) )
@@ -35,7 +32,6 @@ ROUTERS = [
brands.router, brands.router,
material_categories.router, material_categories.router,
attribute_definitions.router, attribute_definitions.router,
category_attributes.router,
materials.router, materials.router,
brand_materials.router, brand_materials.router,
suppliers.router, suppliers.router,
@@ -51,8 +47,6 @@ ROUTERS = [
item_receipts.router, item_receipts.router,
quote_events.router, quote_events.router,
files.router, files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router, standard_conversions.router,
inventory.router, inventory.router,
pm.router, pm.router,
-136
View File
@@ -1,136 +0,0 @@
"""Category attributes — scoped attribute definitions per category (P2 set).
Identified by composite key {category_id}/{attribute_definition_id}.
The nested `attribute` object is ALWAYS included (not expand-based).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/material-categories/{category_id}/attributes",
tags=["category-attributes"],
)
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
return {
"id": str(ca.id),
"category_id": str(ca.category_id),
"attribute_definition_id": str(ca.attribute_definition_id),
"is_required": ca.is_required,
"is_filterable": ca.is_filterable,
"conditional_rules": ca.conditional_rules,
"sort_order": ca.sort_order,
"attribute": {
"attribute_key": attribute.attribute_key,
"label": attribute.label,
"data_type": attribute.data_type,
"unit_id": attribute.unit_id,
"enum_values": attribute.enum_values,
},
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return category
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
return ca_to_dict(ca, attribute)
@router.get("")
async def list_category_attributes(
category_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _category_or_404(db, category_id)
stmt = (
select(CategoryAttribute)
.where(CategoryAttribute.category_id == category_id)
.order_by(CategoryAttribute.sort_order.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
@router.post("", status_code=201)
async def create_category_attribute(
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
):
await _category_or_404(db, category_id)
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
duplicate = await db.scalar(
select(CategoryAttribute.id).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
)
)
if duplicate is not None:
raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id")
ca = CategoryAttribute(
category_id=category_id,
attribute_definition_id=payload.attribute_definition_id,
is_required=payload.is_required,
is_filterable=payload.is_filterable,
conditional_rules=payload.conditional_rules,
sort_order=payload.sort_order,
)
db.add(ca)
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.patch("/{attribute_definition_id}")
async def update_category_attribute(
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
if payload.is_required is not None:
ca.is_required = payload.is_required
if payload.is_filterable is not None:
ca.is_filterable = payload.is_filterable
if payload.conditional_rules is not None:
ca.conditional_rules = payload.conditional_rules
if payload.sort_order is not None:
ca.sort_order = payload.sort_order
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.delete("/{attribute_definition_id}")
async def delete_category_attribute(
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
await db.delete(ca)
await db.flush()
return {"data": {"deleted": True}}
-69
View File
@@ -1,69 +0,0 @@
"""Category reference units — units.html dashboard (P2-26/27).
One reference unit per category (UNIQUE category_id); PUT is an upsert.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
from app.schemas.catalog import CategoryReferenceUnitPut
from app.services.expand_service import category_brief
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(cru.id),
"category_id": str(cru.category_id),
"ref_unit": cru.ref_unit,
"updated_at": to_iso(cru.updated_at),
}
if "category" in expand and hasattr(cru, "_category"):
d["category"] = category_brief(cru._category)
return d
@router.get("")
async def list_category_reference_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(CategoryReferenceUnit)
if category_id:
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {cru.category_id for cru in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for cru in items:
cru._category = cats.get(cru.category_id)
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
@router.put("/{category_id}")
async def upsert_category_reference_unit(
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
if cru is None:
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
db.add(cru)
else:
cru.ref_unit = payload.ref_unit
await db.flush()
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
+4 -89
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_role from app.auth import UserContext, get_optional_user, require_role
@@ -27,13 +27,12 @@ from app.models.inventory import (
InventoryTransaction, InventoryTransaction,
Warehouse, Warehouse,
) )
from app.models.procurement import Project, Supplier, SupplierMaterial from app.models.procurement import Project
from app.schemas.inventory import ( from app.schemas.inventory import (
InventoryTransactionCreate, InventoryTransactionCreate,
WarehouseCreate, WarehouseCreate,
WarehouseUpdate, WarehouseUpdate,
) )
from app.services.expand_service import category_by_material
from app.services.inventory_service import ( from app.services.inventory_service import (
apply_transaction, apply_transaction,
get_warehouse_or_404, get_warehouse_or_404,
@@ -198,6 +197,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None, "physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None, "transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by, "performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date), "transaction_date": to_iso(tx.transaction_date),
"note": tx.note, "note": tx.note,
} }
@@ -315,7 +315,6 @@ async def delete_warehouse(
@router.get("") @router.get("")
async def list_inventory( async def list_inventory(
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None, warehouse_id=None,
material_id=None, material_id=None,
sort: str | None = None, sort: str | None = None,
@@ -333,20 +332,6 @@ async def list_inventory(
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id Warehouse.project_id == project_id
) )
if category_id is not None:
# v2.7: materials carry no category — match through their suppliers
# (any link, mirroring GET /materials?category_id=…).
stmt = stmt.where(
exists(
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == InventoryItem.material_id,
Supplier.category_id == category_id,
)
)
)
if warehouse_id is not None: if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id) stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None: if material_id is not None:
@@ -368,8 +353,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
unit_ids = {i.unit_id for i in items} unit_ids = {i.unit_id for i in items}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()} warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
# v2.7: the category shown for a material comes from its first linked supplier.
material_category = await category_by_material(db, mat_ids)
brands = {} brands = {}
if brand_ids: if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()} brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
@@ -379,7 +362,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [ return [
inventory_to_dict( inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id], i, warehouses[i.warehouse_id], materials[i.material_id],
material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id), None, brands.get(i.brand_id), units.get(i.unit_id),
) )
for i in items for i in items
] ]
@@ -541,71 +524,3 @@ async def transaction_types():
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT", "PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT", "DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
)]} )]}
# ---------------------------------------------------------------------------
# Material selector — filtered by Category + Warehouse (transaction forms)
# ---------------------------------------------------------------------------
@router.get("/materials/options")
async def material_options(
category_id: uuid.UUID,
warehouse_id: uuid.UUID | None = None,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Materials for the transaction form.
Always filtered by category. When `warehouse_id` is given, only materials
that already exist in that warehouse's inventory are returned (so the PM
works with the actual stock of that warehouse); otherwise the full
category list is returned (e.g. first purchase of a new material).
"""
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
# v2.7: a material has no category column — it belongs to a category when a
# linked supplier does (same rule as GET /materials?category_id=…).
stmt = select(Material).where(
Material.status == "active",
exists(
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == Material.id,
Supplier.category_id == category_id,
)
),
)
if warehouse_id is not None:
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
stmt = stmt.where(
Material.id.in_(
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
)
)
stmt = stmt.order_by(Material.name)
materials = (await db.execute(stmt)).scalars().all()
# Brands per material (from brand_materials catalog + brands actually used in stock)
brand_rows = (await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
)).all()
brands_by_material: dict[str, list[dict]] = {}
for mat_id, b_id, b_name in brand_rows:
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
return {"data": [
{
"id": str(m.id),
"name": m.name,
"brands": brands_by_material.get(str(m.id), []),
}
for m in materials
]}
+1 -13
View File
@@ -18,7 +18,7 @@ from app.models.catalog import (
MaterialUnit, MaterialUnit,
Unit, Unit,
) )
from app.models.procurement import LineItem, Supplier, SupplierMaterial from app.models.procurement import LineItem
from app.schemas.catalog import ( from app.schemas.catalog import (
MaterialAttributeValuesPut, MaterialAttributeValuesPut,
MaterialCreate, MaterialCreate,
@@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
async def list_materials( async def list_materials(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand_id=None, brand_id=None,
status: str | None = None, status: str | None = None,
q: str | None = None, q: str | None = None,
@@ -107,17 +106,6 @@ async def list_materials(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Material) stmt = select(Material)
if category_id:
sub = (
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == Material.id,
Supplier.category_id == category_id,
)
)
stmt = stmt.where(exists(sub))
if brand_id: if brand_id:
# WHERE EXISTS brand_materials with that brand_id # WHERE EXISTS brand_materials with that brand_id
sub = select(1).where( sub = select(1).where(
+2 -9
View File
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -201,12 +201,6 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
cats = {}
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 = [] out = []
for d, li, q, m, sup, proj in decisions: for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
@@ -219,8 +213,7 @@ async def pm_on_the_way(
"supplier": {"id": str(sup.id), "name": sup.name}, "supplier": {"id": str(sup.id), "name": sup.name},
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name, "material": {"id": str(m.id), "name": m.name},
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
-13
View File
@@ -45,14 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
def _quote_search(q: str) -> exists: def _quote_search(q: str) -> exists:
like = f"%{q}%" like = f"%{q}%"
@@ -80,7 +72,6 @@ async def get_quote_stats(
project_id=None, project_id=None,
quote_date_from: datetime | None = None, quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None, quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return { return {
@@ -90,7 +81,6 @@ async def get_quote_stats(
project_id=project_id, project_id=project_id,
quote_date_from=quote_date_from, quote_date_from=quote_date_from,
quote_date_to=quote_date_to, quote_date_to=quote_date_to,
category_id=category_id,
) )
} }
@@ -106,7 +96,6 @@ async def list_quotes(
status: str | None = None, status: str | None = None,
supplier_id=None, supplier_id=None,
project_id=None, project_id=None,
category_id=None,
quote_date_from: datetime | None = None, quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None, quote_date_to: datetime | None = None,
q: str | None = None, q: str | None = None,
@@ -137,8 +126,6 @@ async def list_quotes(
) )
) )
) )
if category_id:
stmt = stmt.where(_quote_category_exists(category_id))
if quote_date_from is not None: if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from) stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None: if quote_date_to is not None:
-146
View File
@@ -1,146 +0,0 @@
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
Model: {category_id, brand?, from_unit, to_unit, factor}
1 from_unit = factor × to_unit
brand empty = all brands of the category; brand set = that brand only.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory, UnitConversion
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
from app.services.expand_service import category_brief
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(uc.id),
"category_id": str(uc.category_id),
"brand": uc.brand,
"from_unit": uc.from_unit,
"to_unit": uc.to_unit,
"factor": float(uc.factor),
"updated_at": to_iso(uc.updated_at),
}
if "category" in expand and hasattr(uc, "_category"):
d["category"] = category_brief(uc._category)
return d
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
where = (
UnitConversion.category_id == category_id,
UnitConversion.from_unit == from_unit,
UnitConversion.to_unit == to_unit,
)
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
if uc_or_none is not None:
where = where + (UnitConversion.id != uc_or_none.id,)
return where
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
duplicate = await db.scalar(
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
)
if duplicate is not None:
raise ConflictError("این تبدیل قبلاً ثبت شده است")
@router.get("")
async def list_unit_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand: str | None = None,
from_unit: str | None = None,
to_unit: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(UnitConversion)
if category_id:
stmt = stmt.where(UnitConversion.category_id == category_id)
if brand:
stmt = stmt.where(UnitConversion.brand == brand)
if from_unit:
stmt = stmt.where(UnitConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(UnitConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {uc.category_id for uc in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for uc in items:
uc._category = cats.get(uc.category_id)
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, payload.category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
uc = UnitConversion(
category_id=payload.category_id,
brand=payload.brand,
from_unit=payload.from_unit,
to_unit=payload.to_unit,
factor=payload.factor,
)
db.add(uc)
await db.flush()
return {"data": uc_to_dict(uc)}
@router.get("/{uc_id}")
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
return {"data": uc_to_dict(uc)}
@router.patch("/{uc_id}")
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
new_brand = payload.brand if payload.brand is not None else uc.brand
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
if payload.brand is not None:
uc.brand = payload.brand
if payload.from_unit is not None:
uc.from_unit = payload.from_unit
if payload.to_unit is not None:
uc.to_unit = payload.to_unit
if payload.factor is not None:
uc.factor = payload.factor
await db.flush()
return {"data": uc_to_dict(uc)}
@router.delete("/{uc_id}")
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
await db.delete(uc)
await db.flush()
return {"data": {"deleted": True}}
+1
View File
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None unit_id: int | None = None
specs: dict | None = None specs: dict | None = None
note: str | None = None note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None transaction_date: datetime | None = None
@model_validator(mode="after") @model_validator(mode="after")
+8 -7
View File
@@ -37,13 +37,14 @@ async def track(event: str, properties: dict[str, Any] | None = None, profile_id
"""Record a named event; swallows every error.""" """Record a named event; swallows every error."""
if not _configured(): if not _configured():
return return
body = { # OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
"type": "track", # sends) — a flat body is rejected with 400, which this module swallows by design.
"event": event, # The payload field is "name" (ITrackPayload in packages/validation) — "event" is
"properties": properties or {}, # rejected with a 400 that this module swallows.
} inner: dict[str, Any] = {"name": event, "properties": properties or {}}
if profile_id: if profile_id:
body["profileId"] = profile_id inner["profileId"] = profile_id
body = {"type": "track", "payload": inner}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post( await client.post(
@@ -66,7 +67,7 @@ async def identify(profile_id: str, properties: dict[str, Any] | None = None) ->
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post( await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track", f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json={"type": "identify", "profileId": profile_id, "properties": properties or {}}, json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
headers={ headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID, "openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET, "openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
+5 -48
View File
@@ -35,7 +35,6 @@ from app.models.procurement import (
QuoteEvent, QuoteEvent,
QuoteProjects, QuoteProjects,
Supplier, Supplier,
SupplierMaterial,
Termsheet, Termsheet,
TermsheetScheduleStep, TermsheetScheduleStep,
) )
@@ -203,47 +202,9 @@ def quote_flat(q: Quote) -> dict:
# Materials # Materials
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def category_by_material(db: AsyncSession, material_ids) -> dict:
"""Resolve each material's display category through its FIRST linked supplier.
v2.7 made materials category-free: ``materials.category_id`` no longer
exists and the category lives on the supplier (``suppliers.category_id``).
A material linked to several suppliers therefore has no single category of
its own — we keep the v2.5 first-link-wins rule (oldest supplier_materials
row) so the display value stays stable and matches the old derived column.
Materials with no link, or whose first supplier has no category, resolve to
nothing (the serializers emit null).
"""
ids = {mid for mid in material_ids if mid is not None}
if not ids:
return {}
links = (
await db.execute(
select(SupplierMaterial.material_id, Supplier.category_id)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id.in_(ids))
.order_by(SupplierMaterial.created_at)
)
).all()
first_cat: dict = {}
for material_id, category_id in links:
if category_id is not None and material_id not in first_cat:
first_cat[material_id] = category_id
if not first_cat:
return {}
cats = (
await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(set(first_cat.values()))))
).scalars().all()
by_id = {c.id: c for c in cats}
return {mid: by_id[cid] for mid, cid in first_cat.items() if cid in by_id}
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict: async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
material_ids = [m.id for m in materials] material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}} maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
# Loaded regardless of expand: the non-expanded branch still returns
# category_id, and a silent null there is the bug this replaced.
maps["material_categories"] = await category_by_material(db, material_ids)
unit_ids: set[int] = set() unit_ids: set[int] = set()
if "canonical_unit" in expand: if "canonical_unit" in expand:
@@ -291,13 +252,12 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
"created_at": to_iso(m.created_at), "created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at), "updated_at": to_iso(m.updated_at),
} }
# v2.7: never the material's own column (it no longer has one) — the # Category is a supplier attribute now, not a material one — the key stays
# category comes from the first linked supplier via _category_by_material. # in the payload (null) so the contract shape is unchanged.
material_category = maps["material_categories"].get(m.id)
if "category" in expand: if "category" in expand:
d["category"] = category_brief(material_category) d["category"] = None
else: else:
d["category_id"] = str(material_category.id) if material_category else None d["category_id"] = None
if "canonical_unit" in expand: if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id)) d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else: else:
@@ -340,8 +300,6 @@ class _LineItemData:
def __init__(self): def __init__(self):
self.materials: dict[uuid.UUID, Material] = {} self.materials: dict[uuid.UUID, Material] = {}
self.categories: dict[uuid.UUID, MaterialCategory] = {} self.categories: dict[uuid.UUID, MaterialCategory] = {}
# material id -> its first linked supplier's category (v2.7)
self.material_category: dict[uuid.UUID, MaterialCategory] = {}
self.units: dict[int, Unit] = {} self.units: dict[int, Unit] = {}
self.brands: dict[uuid.UUID, Brand] = {} self.brands: dict[uuid.UUID, Brand] = {}
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {} self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
@@ -373,7 +331,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
if brand_ids: if brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all() brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
data.brands = {b.id: b for b in brands} data.brands = {b.id: b for b in brands}
data.material_category = await category_by_material(db, material_ids)
# Pricing always needs item_termsheets in expanded mode. # Pricing always needs item_termsheets in expanded mode.
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all() its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
@@ -487,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = { d["material"] = {
"id": str(m.id), "id": str(m.id),
"name": m.name, "name": m.name,
"category": category_brief(data.material_category.get(m.id)), "category": None,
"brands": [ "brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code} {"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, []) for bm in data.material_brands.get(m.id, [])
@@ -290,6 +290,7 @@ async def apply_transaction(
transaction_type=t, transaction_type=t,
quantity=quantity, quantity=quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=payload.transaction_date,
note=payload.note, note=payload.note,
) )
@@ -319,6 +320,7 @@ async def apply_transaction(
transaction_type=t, transaction_type=t,
quantity=-quantity, quantity=-quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=payload.transaction_date,
note=payload.note, note=payload.note,
) )
-15
View File
@@ -873,7 +873,6 @@ async def quote_stats(
project_id: UUID | None = None, project_id: UUID | None = None,
quote_date_from=None, quote_date_from=None,
quote_date_to=None, quote_date_to=None,
category_id: UUID | None = None,
) -> dict: ) -> dict:
conditions = [] conditions = []
if supplier_id is not None: if supplier_id is not None:
@@ -884,8 +883,6 @@ async def quote_stats(
conditions.append(Quote.quote_date >= quote_date_from) conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None: if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to) conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = ( rows = (
await db.execute( await db.execute(
@@ -917,18 +914,6 @@ async def quote_stats(
} }
def _quote_has_category(category_id: UUID):
"""EXISTS: quote's supplier belongs to category_id."""
sub = (
select(1)
.where(
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict: async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for ONE project, isolated per project. """Counts + total value per quote status for ONE project, isolated per project.
+1 -1
View File
@@ -209,7 +209,7 @@ async def run() -> None:
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, " "item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
"line_items, quotes, supplier_materials, suppliers, brand_materials, " "line_items, quotes, supplier_materials, suppliers, brand_materials, "
"material_attribute_values, materials, brands, warehouses, projects, users, " "material_attribute_values, materials, brands, warehouses, projects, users, "
"category_reference_units, unit_conversions, standard_conversions " "standard_conversions "
"RESTART IDENTITY CASCADE" "RESTART IDENTITY CASCADE"
)) ))
# Only the 8 canonical topic categories may exist — any leftover slug # Only the 8 canonical topic categories may exist — any leftover slug
+4
View File
@@ -23,9 +23,13 @@ ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
# Empty values disable the feature (analytics.ts is a no-op then). # Empty values disable the feature (analytics.ts is a no-op then).
ARG VITE_OPENPANEL_URL="" ARG VITE_OPENPANEL_URL=""
ARG VITE_OPENPANEL_CLIENT_ID="" ARG VITE_OPENPANEL_CLIENT_ID=""
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
# so it needs its own arg — defined here or the value never reaches the bundle.
ARG VITE_OPENPANEL_SCRIPT_URL=""
ARG VITE_SENTRY_DSN="" ARG VITE_SENTRY_DSN=""
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \ ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \ VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN} VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
# Source code + build # Source code + build
+4 -4
View File
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return ( return (
<div className="flex items-start gap-3 w-full my-2"> <div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ */} {/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-36 shrink-0"> <div className="w-28 shrink-0">
<JalaliDatePicker <JalaliDatePicker
value={milestone.when_iso} value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
/> />
</div> </div>
{/* کادر ورود مبلغ و حروف زیر آن */} {/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<NumberInput <NumberInput
value={milestone.amount} value={milestone.amount}
+23 -17
View File
@@ -7,9 +7,11 @@ export interface NumberInputProps {
onChange: (n: number | null) => void; onChange: (n: number | null) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined; inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد) maxDecimals?: number; // حداکثر رقم اعشار
disabled?: boolean; disabled?: boolean;
} }
@@ -19,6 +21,8 @@ export function NumberInput({
onChange, onChange,
placeholder, placeholder,
className, className,
inputClassName,
align = "right",
showWords = false, showWords = false,
inputMode = "numeric", inputMode = "numeric",
maxDecimals, maxDecimals,
@@ -36,8 +40,6 @@ export function NumberInput({
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : "")); const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
// همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل).
// null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود.
useEffect(() => { useEffect(() => {
const cur = parseNum(text); const cur = parseNum(text);
if (value != null && value !== cur) { if (value != null && value !== cur) {
@@ -60,19 +62,28 @@ export function NumberInput({
const n = parseNum(text); const n = parseNum(text);
const alignClass =
align === "center"
? "!text-center text-center"
: align === "left"
? "!text-left text-left"
: "!text-right text-right";
return ( return (
<div className={cn("min-w-0", className)}> <div className={cn("min-w-0 w-full", className)}>
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60" className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
inputMode={inputMode} inputMode={inputMode}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw); const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw); const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) { if (parsed != null && !trailingSep) {
@@ -85,16 +96,11 @@ export function NumberInput({
}} }}
onBlur={() => commit(text)} onBlur={() => commit(text)}
/> />
{showWords && ( {showWords && n != null && (
<span className={cn("num-pill", n == null && "empty")}> <div className="mt-1 block text-right text-[11px] text-muted">
{n != null ? ( <span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<> <span className="text-muted">({toWords(n)})</span>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span> </div>
</>
) : (
""
)}
</span>
)} )}
</div> </div>
); );
+1 -8
View File
@@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials"; export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects"; export { useProjects, useAddProject } from "./useProjects";
export { export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
useUnits,
useSaveRefUnit,
useSaveConversion,
useDeleteConversion,
useSaveStandardConversion,
useDeleteStandardConversion,
} from "./useUnits";
+4 -40
View File
@@ -1,52 +1,16 @@
// ============================================================ // ============================================================
// hooks واحدها — واحد مرجع، ضرایب دسته‌ای و ثابت // hooks واحدها — تبدیل‌های ثابت جهانی (دسته‌ای‌ها حذف شدند)
// ============================================================ // ============================================================
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types"; import type { StandardConversion } from "../lib/types";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import { useInvalidateStore } from "./useStoreData"; import { useInvalidateStore } from "./useStoreData";
export function useUnits(): { export function useUnits(): { standardConversions: StandardConversion[] } {
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
} {
const categoryUnits = useAppStore((s) => s.categoryUnits);
const unitConversions = useAppStore((s) => s.unitConversions);
const standardConversions = useAppStore((s) => s.standardConversions); const standardConversions = useAppStore((s) => s.standardConversions);
return { categoryUnits, unitConversions, standardConversions }; return { standardConversions };
}
export function useSaveRefUnit() {
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
onMutate: (cu) => setCategoryUnit(cu),
onSettled: () => invalidate(),
});
}
export function useSaveConversion() {
const setConversion = useAppStore((s) => s.setConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
onMutate: (c) => setConversion(c),
onSettled: () => invalidate(),
});
}
export function useDeleteConversion() {
const deleteConversion = useAppStore((s) => s.deleteConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.units.deleteConversion(id),
onMutate: (id) => deleteConversion(id),
onSettled: () => invalidate(),
});
} }
export function useSaveStandardConversion() { export function useSaveStandardConversion() {
+24
View File
@@ -22,8 +22,31 @@ declare global {
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim(); const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim(); const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim();
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim(); const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
/**
* Load the OpenPanel browser script (op1.js) and its queueing shim.
*
* The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the
* script is in flight, so `op("init", …)` can be called immediately and the real
* script drains the queue on load. Without it, every `window.op?.()` call here is a
* silent no-op — which is what happened before this loader existed.
*/
function loadOpenPanelScript(src: string): void {
if (document.querySelector("script[data-openpanel]")) return;
const shim = document.createElement("script");
shim.textContent =
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();';
document.head.appendChild(shim);
const script = document.createElement("script");
script.src = src;
script.async = true;
script.defer = true;
script.dataset.openpanel = "1";
document.head.appendChild(script);
}
export function initAnalytics(): void { export function initAnalytics(): void {
if (SENTRY_DSN) { if (SENTRY_DSN) {
Sentry.init({ Sentry.init({
@@ -35,6 +58,7 @@ export function initAnalytics(): void {
} }
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) { if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
try { try {
if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL);
window.op?.("init", { window.op?.("init", {
apiUrl: OPENPANEL_URL, apiUrl: OPENPANEL_URL,
clientId: OPENPANEL_CLIENT_ID, clientId: OPENPANEL_CLIENT_ID,
+12 -117
View File
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
CategoryUnit,
ConfirmedReceipt, ConfirmedReceipt,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
@@ -30,7 +29,6 @@ import type {
Store, Store,
Supplier, Supplier,
TransactionType, TransactionType,
UnitConversion,
Warehouse, Warehouse,
} from "./types"; } from "./types";
@@ -252,10 +250,6 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data; return res.data;
} }
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" }); const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data; return res.data;
@@ -322,7 +316,6 @@ interface RawSupplier {
phone?: string | null; phone?: string | null;
contact_person?: string | null; contact_person?: string | null;
tax_id?: string | null; tax_id?: string | null;
categories?: string[] | null; // متریال‌های تأمین‌شده
// v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد // v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد
category_id?: string | null; category_id?: string | null;
category?: { id: string; slug: string; name: string } | null; category?: { id: string; slug: string; name: string } | null;
@@ -402,21 +395,6 @@ interface RawQuote {
line_items?: RawLineItem[] | null; line_items?: RawLineItem[] | null;
termsheets?: RawTermsheet[] | null; termsheets?: RawTermsheet[] | null;
} }
interface RawCategoryUnit {
id: string;
category_id: string;
ref_unit: string;
category?: RawCategory | null;
}
interface RawUnitConversion {
id: string;
category_id: string;
brand?: string | null;
from_unit: string;
to_unit: string;
factor: number;
category?: RawCategory | null;
}
interface RawStandardConversion { interface RawStandardConversion {
id: string; id: string;
from_unit: string; from_unit: string;
@@ -562,9 +540,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
topic: topic:
raw.topic || raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""), (raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
// v2.7: دسته‌ی متریال در بک‌اند از نخستین تأمین‌کننده‌ی متصل می‌آید
// (متریالِ بدون تأمین‌کننده → خالی)
supplier_category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "", brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
specs: (raw.attributes || []) specs: (raw.attributes || [])
@@ -645,15 +620,13 @@ function adaptQuote(raw: RawQuote): Quote {
} }
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
// v2.6/v2.7: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل // هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category
// category برمی‌گرداند — همین اولویت اول است. categories قدیمی (ستون حذف‌شده) // برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
// و دسته‌های استنتاج‌شده از پیشنهادها فقط پشتیبان‌اند.
const own = raw.category?.name ? [raw.category.name] : []; const own = raw.category?.name ? [raw.category.name] : [];
const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : [];
return { return {
id: raw.id, id: raw.id,
topic, topic,
categories: own.length ? own : legacy.length ? legacy : categories, categories: own.length ? own : categories,
name: raw.name, name: raw.name,
location: raw.location || "", location: raw.location || "",
phone: raw.phone || "", phone: raw.phone || "",
@@ -671,54 +644,29 @@ function deriveSupplierTopics(
materialsById: Map<string, Material>, materialsById: Map<string, Material>,
): Supplier[] { ): Supplier[] {
const topics = new Map<string, Set<string>>(); const topics = new Map<string, Set<string>>();
const cats = new Map<string, Set<string>>();
for (const q of quotes) { for (const q of quotes) {
for (const li of q.line_items) { for (const li of q.line_items) {
const m = materialsById.get(li.material_id); const m = materialsById.get(li.material_id);
if (!m) continue; if (!m) continue;
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set()); if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
if (m.topic) topics.get(q.supplier_id)!.add(m.topic); if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category);
} }
} }
return rawSuppliers.map((s) => { return rawSuppliers.map((s) => {
const t = topics.get(s.id); const t = topics.get(s.id);
const c = cats.get(s.id);
const derivedTopic = t && t.size ? [...t][0] : ""; const derivedTopic = t && t.size ? [...t][0] : "";
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
// اگر از quotes چیزی پیدا نشد، registry را چک کن // اگر از quotes چیزی پیدا نشد، registry را چک کن
if (!derivedTopic && !derivedCats.length) { if (!derivedTopic) {
const reg = supplierTopicRegistry.get(s.id); const reg = supplierTopicRegistry.get(s.id);
if (reg) { if (reg) return adaptSupplier(s, reg.topic, reg.categories);
return adaptSupplier(s, reg.topic, reg.categories);
}
} }
// v2.7: متریال‌ها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً // دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام
// دسته‌ای نمی‌دهد و صفحه‌ی تأمین‌کننده‌ها بی‌نام می‌شد. دستهٔ ثبت‌شده روی // تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود.
// خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است؛ نامش با نام تاپیک‌ها یکی است.
const backendCat = s.category?.name || ""; const backendCat = s.category?.name || "";
return adaptSupplier( return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
s,
derivedTopic || backendCat,
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
);
}); });
} }
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
}
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
return {
id: raw.id,
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
brand: raw.brand || "",
from_unit: raw.from_unit,
to_unit: raw.to_unit,
factor: raw.factor,
};
}
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion { function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor }; return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
} }
@@ -836,16 +784,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
export const api = { export const api = {
// ---------- کل store (یک بار در ریشه) ---------- // ---------- کل store (یک بار در ریشه) ----------
async listAll(): Promise<Store> { async listAll(): Promise<Store> {
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] = const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
await Promise.all([ await Promise.all([
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`), getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`), getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`), getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`), getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`), getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`), getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
]); ]);
@@ -860,8 +806,6 @@ export const api = {
materials, materials,
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })), projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
quotes, quotes,
categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion), standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes), topics: buildTopics(categoriesRes),
}; };
@@ -1136,7 +1080,7 @@ export const api = {
materials: { materials: {
list: async (): Promise<Material[]> => { list: async (): Promise<Material[]> => {
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`); const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`); const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories))); return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
@@ -1196,46 +1140,6 @@ export const api = {
}, },
units: { units: {
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
return raw.map(adaptCategoryUnit);
},
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`);
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
},
getConversions: async (): Promise<UnitConversion[]> => {
const lookup = await fetchLookup();
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
return raw.map((uc) => adaptUnitConversion(uc, lookup));
},
saveConversion: async (c: UnitConversion): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(c.category));
if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`);
const body = {
category_id: categoryId,
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
};
if (looksLikeUuid(c.id)) {
await patchData(`/unit-conversions/${c.id}`, {
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
});
} else {
await postData("/unit-conversions", body);
}
},
deleteConversion: async (id: string): Promise<void> => {
await deleteData(`/unit-conversions/${id}`);
},
getStandardConversions: async (): Promise<StandardConversion[]> => { getStandardConversions: async (): Promise<StandardConversion[]> => {
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`); const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
return raw.map(adaptStandardConversion); return raw.map(adaptStandardConversion);
@@ -1774,22 +1678,13 @@ export const apiV26 = {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null; transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null; material_id: string; brand_id?: string | null; quantity?: number | null;
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null; physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; transaction_date?: string | null; note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => { }): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body); return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
}, },
types: async (): Promise<{ type: TransactionType; label: string }[]> => { types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
}, },
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const params: Record<string, string> = { category_id };
if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
},
}, },
pm: { pm: {
+8 -66
View File
@@ -2,7 +2,7 @@
// منطق بیزنس مشترک پیشنهادها — پورت از app.js // منطق بیزنس مشترک پیشنهادها — پورت از app.js
// ============================================================ // ============================================================
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types"; import type { LineItem, Material, Quote, Store, Supplier } from "./types";
import { normalize } from "./normalize"; import { normalize } from "./normalize";
import { groupCommas } from "./numutil"; import { groupCommas } from "./numutil";
import { fmtDate } from "./formatters"; import { fmtDate } from "./formatters";
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
id: "", id: "",
name: "", name: "",
topic: "", topic: "",
supplier_category: "",
brand: "", brand: "",
specs: [], specs: [],
base_unit: "", base_unit: "",
@@ -102,15 +101,14 @@ export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | nu
// نام پایه‌ی متریال از نامِ ذخیره‌شده. // نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و // متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی // مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا // name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ
// نامِ پایه باقی بماند. دسته‌ی همان رکوردهای قدیمی همان دسته‌ی نخستین // پایه باقی بماند.
// تأمین‌کننده است (supplier_category). export function materialBaseName(name: string, brand: string, specs: string[]): string {
export function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
const fallback = (name || "").trim(); const fallback = (name || "").trim();
if (!fallback) return ""; if (!fallback) return "";
let base = fallback; let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند // بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, category, ...specs] const parts = [brand, ...specs]
.map((p) => (p || "").trim()) .map((p) => (p || "").trim())
.filter(Boolean) .filter(Boolean)
.sort((a, b) => b.length - a.length); .sort((a, b) => b.length - a.length);
@@ -151,7 +149,7 @@ export function matchMaterial(
materials.find( materials.find(
(m) => (m) =>
base && base &&
normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base && normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
sameIdentity(m), sameIdentity(m),
) )
); );
@@ -216,57 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean); return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
} }
// ---------- واحد مرجع و ضریب تبدیل ----------
// واحد مرجع یک دسته
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
return cu ? cu.ref_unit : "";
}
// ضریب تبدیل با اولویت: دسته‌ای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
export function findConversion(
store: Pick<Store, "unitConversions" | "standardConversions">,
category: string,
brand: string,
fromUnit: string,
toUnit: string,
): UnitConversion | StandardConversion | null {
const list = (store.unitConversions || []).filter(
(c) =>
normalize(c.category) === normalize(category) &&
normalize(c.from_unit) === normalize(fromUnit) &&
normalize(c.to_unit) === normalize(toUnit) &&
(!c.brand || normalize(c.brand) === normalize(brand)),
);
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
if (list[0]) return list[0];
return (
(store.standardConversions || []).find(
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
) || null
);
}
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
export function refConverted(
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
li: LineItem,
): { qty: number; unit: string; perRef: number | null } | null {
const m = materialById(store.materials, li.material_id);
// categoryUnits/unitConversions با «نام دسته» کلید خورده‌اند؛ دسته‌ی متریال
// همان دسته‌ی نخستین تأمین‌کننده است (v2.7). متریال بدون تأمین‌کننده دسته
// ندارد و تبدیلش انجام نمی‌شود.
const ref = refUnitForCategory(store, m.supplier_category);
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
const conv = findConversion(store, m.supplier_category, m.brand, li.unit, ref);
if (!conv) return null;
const qty = (li.quantity || 0) * conv.factor;
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
const perRef = qty > 0 ? Math.round(total / qty) : null;
return { qty, unit: ref, perRef };
}
// واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها // واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها
// واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود // واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] { export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
@@ -336,9 +283,8 @@ export function termsheetSummary(store: Store, q: Quote): { title: string; sub:
// فیلترهای دفتر // فیلترهای دفتر
export function filterQuotes( export function filterQuotes(
store: Store, store: Store,
filters: { category: string; supplier: string; status: string; search: string }, filters: { supplier: string; status: string; search: string },
): Quote[] { ): Quote[] {
const catVal = filters.category.trim();
const supVal = filters.supplier.trim(); const supVal = filters.supplier.trim();
const stVal = filters.status.trim(); const stVal = filters.status.trim();
const q = filters.search.trim().toLowerCase(); const q = filters.search.trim().toLowerCase();
@@ -361,15 +307,11 @@ export function filterQuotes(
if (stKey && quote.status !== stKey) return false; if (stKey && quote.status !== stKey) return false;
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
// v2.7: دسته روی تأمین‌کننده است و بک‌اند هم پیشنهادها را با دسته‌ی
// تأمین‌کننده فیلتر می‌کند — همین قاعده اینجا هم اعمال می‌شود تا لیست و
// فیلتر یک رفتار داشته باشند.
if (catVal && !(supObj.categories || []).includes(catVal)) return false;
if (q) { if (q) {
const hay = const hay =
`${supObj.name} ${quote.project || ""} ` + `${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.supplier_category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false; if (!hay.toLowerCase().includes(q)) return false;
} }
return true; return true;
+1 -23
View File
@@ -22,10 +22,6 @@ export interface Material {
id: string; // "m1", "m2", ... id: string; // "m1", "m2", ...
name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs
topic: string; topic: string;
// دسته‌ی نمایشی — از نخستین تأمین‌کننده‌ی متصل به متریال می‌آید (v2.7:
// خودِ متریال دیگر دسته ندارد و دسته روی تأمین‌کننده است). متریال بدون
// تأمین‌کننده = رشته‌ی خالی. این مقدار کلیدِ تبدیل‌های واحد هم هست.
supplier_category: string;
brand: string; // مثلاً "ذوب آهن" brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه base_unit: string; // واحد پایه
@@ -100,23 +96,6 @@ export interface Quote {
termsheets: Termsheet[]; termsheets: Termsheet[];
} }
// واحد مرجع هر دسته
export interface CategoryUnit {
id: string;
category: string;
ref_unit: string;
}
// ضریب تبدیل (دسته‌ای)
export interface UnitConversion {
id: string;
category: string;
brand: string; // خالی = همه برندها
from_unit: string;
to_unit: string;
factor: number; // 1 from_unit = factor * to_unit
}
// ضریب تبدیل استاندارد (جهانی) // ضریب تبدیل استاندارد (جهانی)
export interface StandardConversion { export interface StandardConversion {
id: string; id: string;
@@ -131,8 +110,6 @@ export interface Store {
materials: Material[]; materials: Material[];
projects: Project[]; projects: Project[];
quotes: Quote[]; quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories
} }
@@ -186,6 +163,7 @@ export interface InventoryTransaction {
physical_qty?: number | null; physical_qty?: number | null;
transfer_pair_id?: string | null; transfer_pair_id?: string | null;
performed_by?: string | null; performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string; transaction_date: string;
note?: string | null; note?: string | null;
material?: { id: string; name: string } | null; material?: { id: string; name: string } | null;
+4 -11
View File
@@ -19,7 +19,7 @@ import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal"; import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
@@ -28,7 +28,7 @@ export function DashboardPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const projects = useProjects(); const projects = useProjects();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { standardConversions } = useUnits();
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds); const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand); const toggleExpand = useAppStore((s) => s.toggleExpand);
@@ -40,8 +40,8 @@ export function DashboardPage() {
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const store = useMemo( const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }), () => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics], [quotes, suppliers, materials, projects, standardConversions, topics],
); );
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at)
@@ -80,12 +80,6 @@ export function DashboardPage() {
})); }));
}; };
// دسته‌ها روی تأمین‌کننده هستند (v2.7) و بک‌اند هم فیلتر پیشنهادها را با
// دسته‌ی تأمین‌کننده انجام می‌دهد — پس گزینه‌های فیلتر از همان‌جا می‌آید.
const categoryOptions = useMemo(
() => [...new Set(suppliers.flatMap((s) => s.categories || []).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fa")),
[suppliers],
);
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return ( return (
@@ -143,7 +137,6 @@ export function DashboardPage() {
<FilterToolbar <FilterToolbar
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
categories={categoryOptions}
suppliers={supplierOptions} suppliers={supplierOptions}
sortDir={sortDir} sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))} onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
+177 -179
View File
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { MilestoneRow } from "../../components/MilestoneRow"; import { TermsheetCard } from "../../components/TermsheetCard";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]); const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]); const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]); const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null); const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return { return {
key: li.id, key: li.id,
origLi: li, origLi: li,
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
// دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست)
cat: m.name || "", cat: m.name || "",
brand: m.brand || "", brand: m.brand || "",
specs: materialSpecs(m), specs: materialSpecs(m),
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice") .list(quoteId, "invoice")
.then((list) => alive && setInvoices(list)) .then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([])); .catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => { return () => {
alive = false; alive = false;
}; };
}, [quoteId]); }, [quoteId]);
if (!q || !ready) return null; const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const tsLabel = (card: EditTsCard, idx: number) => { const getCoveredIds = (card: EditTsCard) => {
const orig = q.termsheets.find((t) => t.id === card.domId); const cardId = card.domId || card.key;
return orig?.label || `گزینه ${faNum(idx + 1)}`; const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
}; };
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const patchItem = (key: string, patch: Partial<EditItemRow>) => { const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
}; };
const addItemRow = () => { const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [ setItemRows((rows) => [
...rows, ...rows,
{ {
key: "new-" + Math.random().toString(36).slice(2, 9), key: newKey,
cat: "", cat: "",
brand: "", brand: "",
specs: [], specs: [],
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "", unit: "",
price: null, price: null,
total: null, total: null,
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [], tsDomIds: defaultTsId ? [defaultTsId] : [],
acceptedTsId: null, acceptedTsId: null,
rejected: false, rejected: false,
restore: true, restore: true,
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const addTsCard = () => { const addTsCard = () => {
setTsCards((cards) => [ setTsCards((cards) => [
...cards, ...cards,
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, {
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
]); ]);
}; };
const removeTsCard = (key: string) => { const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key)); setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
}; };
const confirm = async () => { const confirm = async () => {
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects]; next.projects = [...selectedProjects];
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
? domId ? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId; tsIdMap[domId] = realId;
tsIdMap[card.key] = realId;
const orig = next.termsheets.find((t) => t.id === realId); const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`; const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered"; const st = orig?.status || "offered";
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const total = row.total; const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
// هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد)
let material = matchMaterial(materialsCopy, cat, brand, specs); let material = matchMaterial(materialsCopy, cat, brand, specs);
let materialId: string; let materialId: string;
if (material) { if (material) {
@@ -308,7 +362,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
brand, brand,
specs, specs,
base_unit: unit, base_unit: unit,
supplier_category: "",
}; };
materialsCopy.push(nm); materialsCopy.push(nm);
const created = await addMaterial.mutateAsync(nm); const created = await addMaterial.mutateAsync(nm);
@@ -323,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!rejected) { if (!rejected) {
row.tsDomIds.forEach((domId) => { row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId; const realId = tsIdMap[domId] || domId;
if (realId) termsheet_ids.push(realId); if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
}); });
if (!termsheet_ids.length && next.termsheets.length) { if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id); termsheet_ids.push(next.termsheets[0].id);
@@ -360,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const referencedTs = new Set<string>(); const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null; li.accepted_ts_id = null;
@@ -407,7 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<> <>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl"> <DialogContent className="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"> <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> <DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5"> <div className="text-[12px] text-gray-500 truncate mt-0.5">
@@ -422,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
)} )}
{/* تب‌بار سفید و استاندارد */} {/* تب‌بار */}
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0"> <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 <button
type="button" type="button"
@@ -493,12 +546,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</button> </button>
</div> </div>
{/* محتوای مودال — پس‌زمینه کاملاً سفید */} {/* محتوا */}
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800"> <div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
{/* تب ۱: اقلام و مشخصات */} {/* تب ۱: اقلام */}
{activeTab === "items" && ( {activeTab === "items" && (
<div className="space-y-4"> <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="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
@@ -537,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
{/* لیست اقلام */} {/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span> <span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
@@ -574,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
<div className="space-y-3"> <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"> <div className="grid grid-cols-2 gap-2.5">
<label className="text-[11px] font-semibold text-gray-600">متریال</label> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right 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="نام متریال" placeholder="نام متریال"
value={row.cat} value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })} onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600">برند</label> <label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60" className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right 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="برند" placeholder="برند"
value={row.brand} value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })} onChange={(e) => patchItem(row.key, { brand: e.target.value })}
@@ -598,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2.5"> {/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label> <label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox <MultiSelectCombobox
disabled={hasReceipts} disabled={hasReceipts}
values={row.specs} values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })} onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions} options={specOptions}
placeholder="مشخصه" placeholder="انتخاب مشخصات..."
/> className="min-w-0"
</div> />
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد سنجش"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div> </div>
{/* جعبه ۳ عددی مقادیر */} {/* بخش مقادیر و محاسبات مالی */}
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200"> <div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
<div className="flex flex-col gap-1"> {/* ردیف ۳: تعداد + واحد سنجش */}
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label> <div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<NumberInput <div className="flex flex-col gap-1 min-w-0">
disabled={hasReceipts} <label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
value={row.qty} <NumberInput
onChange={(qty) => { disabled={hasReceipts}
patchItem(row.key, { value={row.qty}
qty, onChange={(qty) => {
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total, patchItem(row.key, {
}); qty,
}} total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
placeholder="تعداد" });
/> }}
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</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 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد (شاخه، متر، کیلو...)"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div> </div>
<div className="flex flex-col gap-1"> {/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label> <div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.price} value={row.price}
onChange={(price) => { onChange={(price) => {
patchItem(row.key, { patchItem(row.key, {
price, price,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total, total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
}); });
}} }}
placeholder="واحد" placeholder="قیمت هر واحد"
showWords
maxDecimals={2} maxDecimals={2}
className="!text-right text-right"
/> />
</div> </div>
<div className="flex flex-col gap-1"> {/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label> <div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.total} value={row.total}
@@ -667,14 +730,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price, t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
}); });
}} }}
placeholder="جمع" placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
/> />
</div> </div>
</div> </div>
{/* شرایط پرداخت برای این قلم */} {row.rejected && (
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
{row.rejected ? (
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer"> <label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -684,44 +748,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/> />
بازگرداندن این قلم به معامله بازگرداندن این قلم به معامله
</label> </label>
) : ( </div>
<div className="flex flex-col gap-1.5"> )}
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
<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",
)}
>
<input
type="checkbox"
disabled={hasReceipts}
className="accent-blue-600 h-3.5 w-3.5 rounded"
checked={isSelected}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
<span>{tsLabel(card, cIdx)}</span>
</label>
);
})}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
))} ))}
@@ -742,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
)} )}
{/* تب ۲: شرایط پرداخت */} {/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
{activeTab === "terms" && ( {activeTab === "terms" && (
<div className="space-y-3.5"> <div className="space-y-3.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div> <div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
<div className="text-[11.5px] text-gray-500 mt-0.5">
روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
</div>
</div>
{!hasReceipts && ( {!hasReceipts && (
<Button <Button
type="button" type="button"
@@ -763,70 +796,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tsCards.map((card, idx) => ( {tsCards.map((card, idx) => (
<div <TermsheetCard
key={card.key} key={card.key}
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs" index={idx + 1}
> milestones={card.milestones}
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5"> onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
<div className="flex items-center gap-1.5"> items={itemOptions}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600"> coveredIds={getCoveredIds(card)}
{faNum(idx + 1)} onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
</span> onRemove={() => removeTsCard(card.key)}
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span> />
<span className="text-[11px] text-gray-500 font-medium">
({faNum(card.milestones.length)} مرحله)
</span>
</div>
{!hasReceipts && tsCards.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف گزینه"
onClick={() => removeTsCard(card.key)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="flex flex-col gap-2 mb-3">
{card.milestones.map((ms, i) => (
<MilestoneRow
key={i}
milestone={ms}
disabled={hasReceipts}
onChange={(nextMs) =>
updateTs(card.key, {
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
}
/>
))}
</div>
{!hasReceipts && (
<Button
type="button"
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 }],
})
}
>
<Plus className="h-3.5 w-3.5 me-1" />
افزودن مرحله پرداخت
</Button>
)}
</div>
))} ))}
</div> </div>
</div> </div>
@@ -892,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)} loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/> />
</div> </div>
{/* پیش‌فاکتورها — در همه‌ی مراحل چرخه‌ی پیشنهاد قابل افزودن/ویرایش است */}
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">پیش‌فاکتورها</div>
<FileList
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 : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
</div> </div>
)} )}
</div> </div>
{/* فوتر سفید، محکم و چسبان */} {/* فوتر */}
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2"> <DialogFooter className="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">
{q.status === "offered" ? ( {q.status === "offered" ? (
<Button <Button
@@ -925,7 +923,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* مودال تایید حذف سفید */} {/* مودال تایید حذف */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}> <Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900"> <DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader> <DialogHeader>
+2 -28
View File
@@ -3,7 +3,6 @@ import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
export interface Filters { export interface Filters {
category: string;
supplier: string; supplier: string;
status: string; status: string;
search: string; search: string;
@@ -40,7 +39,6 @@ const STATUS_TO_KEY: Record<string, string> = {
export interface FilterToolbarProps { export interface FilterToolbarProps {
filters: Filters; filters: Filters;
onChange: (f: Filters) => void; onChange: (f: Filters) => void;
categories: string[];
suppliers: { id: string; name: string }[]; suppliers: { id: string; name: string }[];
sortDir: "desc" | "asc"; sortDir: "desc" | "asc";
onToggleSort: () => void; onToggleSort: () => void;
@@ -49,7 +47,6 @@ export interface FilterToolbarProps {
export function FilterToolbar({ export function FilterToolbar({
filters, filters,
onChange, onChange,
categories,
suppliers, suppliers,
sortDir, sortDir,
onToggleSort, onToggleSort,
@@ -58,10 +55,10 @@ export function FilterToolbar({
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch }); const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
// شمارش تعداد فیلترهای فعال به جز سرچ // شمارش تعداد فیلترهای فعال به جز سرچ
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length; const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length;
const clearFilters = () => { const clearFilters = () => {
onChange({ ...filters, category: "", supplier: "", status: "" }); onChange({ ...filters, supplier: "", status: "" });
}; };
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس // تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
@@ -153,18 +150,6 @@ export function FilterToolbar({
{/* دراپ‌داون‌ها داخل کشو */} {/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5"> <div className="flex flex-col gap-3.5">
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه دسته‌ها"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label> <label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label>
<Combobox <Combobox
@@ -204,17 +189,6 @@ export function FilterToolbar({
{/* ===== نمای دسکتاپ (md به بالا) ===== */} {/* ===== نمای دسکتاپ (md به بالا) ===== */}
<div className="hidden md:flex flex-wrap items-end gap-3"> <div className="hidden md:flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field min-w-[170px] flex-1"> <div className="field min-w-[170px] flex-1">
<label>تأمین‌کننده</label> <label>تأمین‌کننده</label>
<Combobox <Combobox
+81 -32
View File
@@ -35,7 +35,6 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
); );
} }
// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id); const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q); const expired = isExpired(q);
@@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
if (q.status === "offered") { if (q.status === "offered") {
acceptedCell = ( acceptedCell = (
<button <button
onClick={onAccept} onClick={(e) => {
e.stopPropagation();
onAccept();
}}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform" className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
@@ -134,53 +136,81 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
expanded && "border-accent/60 ring-1 ring-accent/20", expanded && "border-accent/60 ring-1 ring-accent/20",
)} )}
> >
{/* سربرگ کارت */} {/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */}
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5"> <div
onClick={onToggleExpand}
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
{sup.name.slice(0, 1)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5"> {/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */}
<div className="flex items-center">
<Link <Link
to={`/suppliers/${q.supplier_id}`} to={`/suppliers/${q.supplier_id}`}
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]" onClick={(e) => e.stopPropagation()}
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
> >
{sup.name} {sup.name}
</Link> </Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<span
className={cn(
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
q.is_official
? "bg-green-bg text-green"
: "bg-slate-bg text-muted",
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span>
</div> </div>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{sup.location && (
<span className="truncate text-[10px] text-muted sm:text-[11px]">
{sup.location}
</span>
)}
{q.is_official && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
<span>فاکتور رسمی</span>
</span>
)}
</div>
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{fulfillmentBadge ?? ( {fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs"> <Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]} {STATUS_LABEL[q.status]}
</Badge> </Badge>
)} )}
{/* دکمه ویرایش */}
<button <button
className="chip h-7 px-2 sm:h-auto sm:px-2.5" type="button"
onClick={onEdit} className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد" title="ویرایش پیشنهاد"
aria-label="ویرایش" aria-label="ویرایش"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span> <span className="hidden sm:inline">ویرایش</span>
</button> </button>
{/* دکمه فلش اکسپند در هدر */}
<button <button
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5" type="button"
onClick={onToggleExpand} className={cn(
title={expanded ? "جمع کردن" : "نمایش جزئیات"} "inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
expanded && "bg-accent-bg text-accent-dark border-accent/40",
)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
aria-expanded={expanded} aria-expanded={expanded}
> >
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} /> <ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -188,9 +218,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</div> </div>
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */} {/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"> <div
{/* ۱. پروژه — تمام‌عرض در موبایل */} onClick={onToggleExpand}
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
>
{/* ۱. پروژه */}
<InfoCell label="پروژه" className="col-span-2 md:col-span-1"> <InfoCell label="پروژه" className="col-span-2 md:col-span-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]"> <div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" /> <Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
@@ -200,7 +233,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</InfoCell> </InfoCell>
{/* ۲. اقلام — نمایش عمودی چندخطی */} {/* ۲. اقلام */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1"> <InfoCell label="اقلام" className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]"> <div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? ( {activeItems.length > 0 ? (
@@ -221,6 +254,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Link <Link
key={w.unit} key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`} to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
onClick={(e) => e.stopPropagation()}
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]" className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`} title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
> >
@@ -230,6 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))} ))}
</InfoCell> </InfoCell>
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1"> <InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? ( {pc ? (
<div> <div>
@@ -286,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</span> </span>
</div> </div>
{/* جزئیات تکمیلی بازشونده */} {/* دکمه تمام‌عرض توضیحات بیشتر */}
{expanded && <QuoteDetail store={store} q={q} />} <button
type="button"
onClick={onToggleExpand}
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
>
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
</button>
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */}
{expanded && (
<div onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} />
</div>
)}
</div> </div>
); );
} }
+1 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
import type { Attachment, Quote, Store } from "../../lib/types"; import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { import {
@@ -7,7 +7,6 @@ import {
itemTermsheetIds, itemTermsheetIds,
lineItemTotal, lineItemTotal,
materialById, materialById,
refConverted,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters"; import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
@@ -76,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid)); const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
const total = lineItemTotal(li); const total = lineItemTotal(li);
const projs = itemProjects(q, li); const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی): // رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد). // مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
@@ -140,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>} {whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
</div> </div>
)} )}
{conv && (
<div className="fb-conv">
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
</div>
)}
</div> </div>
); );
}; };
@@ -62,6 +62,7 @@ export function ChangeInventoryPage() {
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString()); const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [specsChips, setSpecsChips] = useState<string[]>([]); const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]); const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -86,10 +87,20 @@ export function ChangeInventoryPage() {
); );
const formRecords = useMemo(() => { const formRecords = useMemo(() => {
const txt = matTxt.trim(); const txt = matTxt.trim().toLowerCase();
const wid = type === "TRANSFER" ? fromId : whId; const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => { return data.inventory.filter((r) => {
if (r.warehouse?.id !== wid) return false; if (r.warehouse?.id !== wid) return false;
if (type === "WITHDRAWAL") {
if (r.quantity_number <= 0) return false;
if (txt) {
const mMatch = r.material.name.toLowerCase().includes(txt);
const bMatch = r.brand?.name?.toLowerCase().includes(txt) ?? false;
const sMatch = Object.values(r.specs || {}).some((v) => String(v).toLowerCase().includes(txt));
if (!mMatch && !bMatch && !sMatch) return false;
}
return true;
}
if (txt && !r.material.name.includes(txt)) return false; if (txt && !r.material.name.includes(txt)) return false;
return true; return true;
}); });
@@ -186,6 +197,7 @@ export function ChangeInventoryPage() {
setQty(""); setQty("");
setSpecsChips([]); setSpecsChips([]);
setNote(""); setNote("");
setDeliveredTo("");
await data.refresh(); await data.refresh();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت"); toast.error(err instanceof Error ? err.message : "خطا در ثبت");
@@ -220,7 +232,7 @@ export function ChangeInventoryPage() {
let m = matByName(matName); let m = matByName(matName);
if (!m) { if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, category: null, canonical_unit: null }; m = { id: created.id, name: created.name, canonical_unit: null };
} }
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
@@ -235,6 +247,9 @@ export function ChangeInventoryPage() {
}; };
const submitList = async (t: TransactionType) => { const submitList = async (t: TransactionType) => {
if (t === "WITHDRAWAL" && !deliveredTo.trim()) {
return toast.error("نام تحویل‌گیرنده اقلام الزامی است");
}
const ops: Record<string, unknown>[] = rows const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => { .map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty); const n = parseNum(r.qty);
@@ -244,7 +259,18 @@ export function ChangeInventoryPage() {
if (t === "STOCK_COUNT") { if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
} }
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() }; return {
transaction_type: t,
warehouse_id: whId,
material_id: rec.material.id,
brand_id: rec.brand?.id || null,
quantity: n,
unit_id: rec.unit?.id ?? null,
specs: rec.specs || {},
note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(),
};
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -370,6 +396,9 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
} }
if (type === "WITHDRAWAL") {
return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0));
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") { if (type === "FOUND") {
@@ -486,7 +515,36 @@ export function ChangeInventoryPage() {
</div> </div>
</div> </div>
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && ( {type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
list="dl-mat"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
</label>
<input
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…"
value={deliveredTo}
onChange={(e) => setDeliveredTo(e.target.value)}
/>
</div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label> <label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input <input
@@ -653,10 +711,11 @@ export function ChangeInventoryPage() {
setRows={setRows} setRows={setRows}
records={formRecords} records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)} unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={type === "WITHDRAWAL" || !matTxt.trim()}
emptyMsg={ emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید." !whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود." : (type !== "WITHDRAWAL" && !matTxt.trim()) ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید." : !formRecords.length ? (matTxt.trim() ? "برای این عبارت در انبار انتخابی رکوردی یافت نشد." : "هیچ کالای دارای موجودی در این انبار یافت نشد.")
: "" : ""
} }
/> />
@@ -673,6 +732,7 @@ export function ChangeInventoryPage() {
setRows={setRows} setRows={setRows}
records={formRecords} records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)} unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={!matTxt.trim()}
emptyMsg={ emptyMsg={
!fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید." !fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید…" : !matTxt.trim() ? "نام متریال را تایپ کنید…"
@@ -778,57 +838,69 @@ export function ChangeInventoryPage() {
); );
} }
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: { function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg, showMaterial = true }: {
isCount: boolean; isCount: boolean;
rows: RowDraft[]; rows: RowDraft[];
setRows: (r: RowDraft[]) => void; setRows: (r: RowDraft[]) => void;
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[]; records: { id: string; material?: { id: string; name: string }; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
unitNameFn: (id: number | null | undefined) => string; unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string; emptyMsg: string;
showMaterial?: boolean;
}) { }) {
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>; if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
return ( return (
<div className="overflow-x-auto"> <div>
<table className="w-full text-[12.5px] min-w-[500px]"> <div className="overflow-x-auto max-h-[420px] overflow-y-auto border border-line rounded-lg">
<thead> <table className="w-full text-[12.5px] min-w-[560px]">
<tr className="border-b border-line text-right text-muted"> <thead className="sticky top-0 bg-panel z-10 shadow-2xs border-b border-line">
<th className="py-2 pe-3">مشخصه‌ها</th> <tr className="text-right text-muted">
<th className="py-2 pe-3">برند</th> {showMaterial && <th className="py-2.5 px-3">متریال</th>}
<th className="py-2 pe-3">واحد</th> <th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2 pe-3">موجودی فعلی</th> <th className="py-2.5 px-3">برند</th>
<th className="py-2 pe-3">{isCount ? "شمارش‌شده" : "تعداد"}</th> <th className="py-2.5 px-3">واحد</th>
<th className="py-2">یادداشت</th> <th className="py-2.5 px-3">موجودی فعلی</th>
</tr> <th className="py-2.5 px-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
</thead> <th className="py-2.5 px-3">یادداشت</th>
<tbody>
{records.map((r, i) => (
<tr key={r.id} className="border-b border-line/50">
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className="divide-y divide-line/40">
</table> {records.map((r, i) => (
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div> <tr key={r.id} className="hover:bg-bg/40 transition-colors">
{showMaterial && (
<td className="py-2 px-3 font-bold text-ink">
{r.material?.name || "—"}
</td>
)}
<td className="py-2 px-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="py-2 px-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2 px-3">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div> </div>
); );
} }
@@ -129,9 +129,10 @@ export function DailyControlPage() {
const wGroups = useMemo(() => { const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>(); const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => { withdrawals.forEach((t) => {
const g = gmap.get(t.inventory_id); const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
const g = gmap.get(gKey);
if (!g) { if (!g) {
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t }); gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else { } else {
g.q += Math.abs(t.quantity_number || 0); g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date; if (t.transaction_date > g.last) g.last = t.transaction_date;
@@ -324,6 +325,12 @@ export function DailyControlPage() {
whName(data.warehouses, g.tx.warehouse_id) whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")} ].filter(Boolean).join(" · ")}
</span> </span>
{g.tx.delivered_to && (
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
<span className="text-slate-500 font-normal">تحویل به:</span>
<span>{g.tx.delivered_to}</span>
</span>
)}
</span> </span>
<span className="text-xs font-bold text-rose-600 tabular-nums"> <span className="text-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)} {faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
+5 -54
View File
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav"; import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
@@ -55,8 +55,7 @@ export function InventoryPage() {
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
category: "",
warehouse: params.get("warehouse") || "", warehouse: params.get("warehouse") || "",
material: "", material: "",
project: "", project: "",
@@ -81,7 +80,6 @@ export function InventoryPage() {
inventory.forEach((r) => { inventory.forEach((r) => {
const whId = r.warehouse?.id; const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return; if (!whId || !visibleIds.has(whId)) return;
if (filters.category && r.material.category?.id !== filters.category) return;
if (filters.warehouse && whId !== filters.warehouse) return; if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return; if (filters.material && !r.material.name.includes(filters.material.trim())) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
@@ -132,7 +130,7 @@ export function InventoryPage() {
.slice() .slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project)); const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) { if (!user || (!isAdmin && !isPM)) {
@@ -248,40 +246,6 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */} {/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && ( {showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5"> <div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* دسته‌بندی‌ها */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">دسته‌بندی متریال:</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
!filters.category
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
همه دسته‌بندی‌ها
</button>
{lookup.categories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.category === c.id
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{c.name}
</button>
))}
</div>
</div>
{/* پروژه‌ها (مخصوص ادمین) */} {/* پروژه‌ها (مخصوص ادمین) */}
{isAdmin && lookup.projects.length > 0 && ( {isAdmin && lookup.projects.length > 0 && (
<div> <div>
@@ -324,7 +288,7 @@ export function InventoryPage() {
<button <button
type="button" type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1" className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })} onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
پاک کردن فیلترها پاک کردن فیلترها
@@ -347,7 +311,6 @@ export function InventoryPage() {
) : ( ) : (
groups.map((g) => { groups.map((g) => {
const isExp = expandedKey === g.key; const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return ( return (
<InventoryMobileCard <InventoryMobileCard
key={g.key} key={g.key}
@@ -355,7 +318,6 @@ export function InventoryPage() {
isExp={isExp} isExp={isExp}
isAdmin={isAdmin} isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)} matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)} wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""} projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)} unit={unitName(lookup.units, g.unitId)}
@@ -410,7 +372,6 @@ export function InventoryPage() {
<tbody> <tbody>
{groups.map((g) => { {groups.map((g) => {
const isExp = expandedKey === g.key; const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return ( return (
<GroupRow <GroupRow
key={g.key} key={g.key}
@@ -418,7 +379,6 @@ export function InventoryPage() {
isExp={isExp} isExp={isExp}
isAdmin={isAdmin} isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)} matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)} wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""} projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)} unit={unitName(lookup.units, g.unitId)}
@@ -475,7 +435,6 @@ function InventoryMobileCard({
isExp, isExp,
isAdmin, isAdmin,
matName, matName,
matCatName,
wh, wh,
projName, projName,
unit, unit,
@@ -486,7 +445,6 @@ function InventoryMobileCard({
isExp: boolean; isExp: boolean;
isAdmin: boolean; isAdmin: boolean;
matName: string; matName: string;
matCatName: string;
wh: string; wh: string;
projName: string; projName: string;
unit: string; unit: string;
@@ -508,11 +466,6 @@ function InventoryMobileCard({
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words"> <h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
{matName} {matName}
</h3> </h3>
{matCatName !== "—" && (
<span className="mt-0.5 inline-block text-[11px] text-muted">
{matCatName}
</span>
)}
</div> </div>
<div <div
className={cn( className={cn(
@@ -574,12 +527,11 @@ function InventoryMobileCard({
); );
} }
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: { function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
g: Group; g: Group;
isExp: boolean; isExp: boolean;
isAdmin: boolean; isAdmin: boolean;
matName: string; matName: string;
matCatName: string;
wh: string; wh: string;
projName: string; projName: string;
unit: string; unit: string;
@@ -591,7 +543,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}> <tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td> <td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td> <td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
<div>{wh}</div> <div>{wh}</div>
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>} {isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
export interface LookupData { export interface LookupData {
categories: { id: string; slug: string; name: string }[];
units: { id: number; code: string; name_fa: string | null }[]; units: { id: number; code: string; name_fa: string | null }[];
brands: { id: string; name: string }[]; brands: { id: string; name: string }[];
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]; materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[]; projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
} }
@@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData {
const [inventory, setInventory] = useState<InventoryRow[]>([]); const [inventory, setInventory] = useState<InventoryRow[]>([]);
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]); const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
const [lookup, setLookup] = useState<LookupData>({ const [lookup, setLookup] = useState<LookupData>({
categories: [], units: [], brands: [], materials: [], projects: [], units: [], brands: [], materials: [], projects: [],
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([ const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
apiV26.lookup.categories(),
apiV26.lookup.units(), apiV26.lookup.units(),
apiV26.lookup.brands(), apiV26.lookup.brands(),
apiV26.lookup.materials(), apiV26.lookup.materials(),
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
apiV26.inventory.list({ project_id: projectId }), apiV26.inventory.list({ project_id: projectId }),
apiV26.inventory.transactions({ project_id: projectId }), apiV26.inventory.transactions({ project_id: projectId }),
]); ]);
setLookup({ categories: cats, units, brands, materials: mats, projects: projs }); setLookup({ units, brands, materials: mats, projects: projs });
setWarehouses(whs); setWarehouses(whs);
setInventory(inv); setInventory(inv);
setTransactions(txs); setTransactions(txs);
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
return m ? m.name : "—"; return m ? m.name : "—";
} }
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
const c = categories.find((x) => x.id === id);
return c ? c.name : "—";
}
export function projName(projects: LookupData["projects"], id: string | null | undefined): string { export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
const p = projects.find((x) => x.id === id); const p = projects.find((x) => x.id === id);
return p ? p.name : "—"; return p ? p.name : "—";
+2 -10
View File
@@ -19,7 +19,6 @@ import {
lineItemTotal, lineItemTotal,
materialById, materialById,
materialLabel, materialLabel,
refConverted,
supplierById, supplierById,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
@@ -81,11 +80,11 @@ export function ProjectsPage() {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { standardConversions } = useUnits();
const addProject = useAddProject(); const addProject = useAddProject();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }; const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null); const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -189,17 +188,10 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
const itemLine = (li: Quote["line_items"][number]) => { const itemLine = (li: Quote["line_items"][number]) => {
const total = lineItemTotal(li); const total = lineItemTotal(li);
const conv = refConverted(store, li);
return ( return (
<div key={li.id}> <div key={li.id}>
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "} {matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
<b>{faNum(total || 0)} تومان</b> <b>{faNum(total || 0)} تومان</b>
{conv && (
<span className="pj-conv">
{" "}
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
</span>
)}
</div> </div>
); );
}; };
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react"; import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks"; import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory"; import { PurchaseHistory } from "./PurchaseHistory";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده // صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() { export function SupplierDetailPage() {
useStoreData(); useStoreData();
@@ -29,6 +39,19 @@ export function SupplierDetailPage() {
const [linked, setLinked] = useState<string[]>([]); const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// محاسبه میانگین زمان تحویل این تأمین‌کننده بر اساس خریدهای تحویل‌شده
const avgLeadTime = useMemo(() => {
if (!s?.id) return null;
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);
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
}, [quotes, s?.id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
let alive = true; let alive = true;
@@ -48,21 +71,8 @@ export function SupplierDetailPage() {
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]); }, [materials]);
// متریال‌های خریداری‌شده از این تأمین‌کننده (از پیشنهادها) // دسته‌ی این تأمین‌کننده — دسته فقط برچسبِ خودِ تأمین‌کننده است (v2.7)
const purchased = useMemo(() => { const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
const out = new Set<string>();
quotes.forEach((q) => {
if (q.supplier_id !== id) return;
(q.line_items || []).forEach((li) => {
if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id);
// دسته‌ی نمایشی متریال = دسته‌ی نخستین تأمین‌کننده‌ی متصل (v2.7)
if (!m || !m.supplier_category) return;
out.add(m.supplier_category);
});
});
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}, [quotes, materials, id]);
if (!s) { if (!s) {
return ( return (
@@ -81,7 +91,6 @@ export function SupplierDetailPage() {
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`; const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => { const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return; if (!id || busy) return;
setBusy(true); setBusy(true);
void (async () => { void (async () => {
@@ -129,21 +138,51 @@ export function SupplierDetailPage() {
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" /> <Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout> <PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-xl font-extrabold">{s.name}</div> <div className="flex items-center gap-3">
<Badge variant="outline">{topicName}</Badge> <div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge>
</div>
{avgLeadTime != null && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
<Clock className="h-3.5 w-3.5 text-emerald-400" />
<span>{avgLeadTime} روز</span>
</span>
)}
</div> </div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
<span> <span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"} <MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span> </span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"} {/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
</span> <div className="flex items-center">
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
<b className="me-1">تلفن:</b>
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
dir="ltr"
title="تماس مستقیم با تأمین‌کننده"
>
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span className="text-muted">—</span>
)}
</div>
<span> <span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"} <User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span> </span>
<span>
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
</span>
</div> </div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Link <Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`} to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
@@ -164,7 +203,7 @@ export function SupplierDetailPage() {
</div> </div>
</div> </div>
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */} {/* متریال‌های این تأمین‌کننده */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold"> <h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده <Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
@@ -204,7 +243,7 @@ export function SupplierDetailPage() {
)} )}
</div> </div>
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */} {/* سابقه خرید از این تأمین‌کننده */}
<div className="mt-4 max-w-4xl"> <div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} /> <PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div> </div>
+35 -4
View File
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters"; import { fmtNum } from "../../lib/formatters";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
return suppliers.filter( return suppliers.filter(
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)), (s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
@@ -29,7 +38,11 @@ export function SupplierCard({
return ( return (
<div className="sup-card"> <div className="sup-card">
<div className="sc-head flex items-start justify-between gap-2"> <div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug"> {/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */}
<Link
to={`/suppliers/${s.id}`}
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
>
{s.name} {s.name}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
@@ -49,17 +62,34 @@ export function SupplierCard({
)} )}
</div> </div>
</div> </div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"} <MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
</span> </span>
<span>
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"} {/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
</span> {s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
title="تماس مستقیم با تأمین‌کننده"
dir="ltr"
>
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span>
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
</span>
)}
<span> <span>
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"} <User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
</span> </span>
</div> </div>
{materials.length > 0 && ( {materials.length > 0 && (
<div className="mt-2 border-t border-line pt-1.5"> <div className="mt-2 border-t border-line pt-1.5">
<div className="mb-1 flex flex-wrap gap-1"> <div className="mb-1 flex flex-wrap gap-1">
@@ -71,6 +101,7 @@ export function SupplierCard({
</div> </div>
</div> </div>
)} )}
<div className="sc-actions"> <div className="sc-actions">
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline"> <Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
+189 -133
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus } from "lucide-react";
import type { Material } from "../../lib/types"; import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks"; import { useMaterials } from "../../hooks";
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
import { faNum } from "../../lib/formatters";
export interface ItemDraft { export interface ItemDraft {
tempId: string; tempId: string;
@@ -51,7 +53,6 @@ export function materialOptions(
apiBrands: string[] = [], apiBrands: string[] = [],
apiSpecs: string[] = [], apiSpecs: string[] = [],
): string[] { ): string[] {
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials; let list = materials;
const cat = item.cat.trim(); const cat = item.cat.trim();
@@ -67,21 +68,17 @@ export function materialOptions(
}; };
if (field === "brand") { if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و متریال
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b)); apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa")); return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
} }
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ
// هم‌نام پیشنهاد می‌شوند.
if (cat) if (cat)
list = list.filter( list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat), (m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
); );
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
const base = new Set<string>(); const base = new Set<string>();
apiSpecs.forEach((s) => s && base.add(s)); apiSpecs.forEach((s) => s && base.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s))); materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
@@ -90,7 +87,7 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa")); return [...base].sort((a, b) => a.localeCompare(b, "fa"));
} }
// مرحله ۲ — جدول پویای اقلام سفارش // مرحله ۲ — مدیریت اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials(); const materials = useMaterials();
@@ -109,7 +106,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
const allMaterialNames = useMemo(() => { const allMaterialNames = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
materials.forEach((m) => { materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs, m.supplier_category); const base = materialBaseName(m.name, m.brand, m.specs);
if (base) set.add(base); if (base) set.add(base);
}); });
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
@@ -167,146 +164,205 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return; if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs }; rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
} }
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
// همان هویتِ متریال انجام می‌شود، نه دسته.
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs); const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
if (dup) return; if (dup) return;
onRemember(rec); onRemember(rec);
}; };
return ( return (
<div> <div className="space-y-4">
<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-blue-600 text-[12px] font-bold text-white">۲</span> <div className="space-y-1">
اقلام سفارش <div className="flex items-center justify-between">
</h3> <div className="flex items-center gap-2">
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} /> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
۲
<div className="item-row wizard-item-row item-row-head mb-1 pe-7"> </span>
<span>متریال</span> <h3 className="text-sm sm:text-base font-bold text-ink">
<span>برند</span> اقلام سفارش
<span>مشخصه</span> </h3>
<span>تعداد</span> </div>
<span>واحد</span> <span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
<span>قیمت واحد</span> {faNum(items.length)} قلم
<span>قیمت کل</span> </span>
</div>
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینه‌های جدید با تایپ ذخیره می‌شوند.
</p>
</div> </div>
<div data-field="line_items" className="flex flex-col gap-2"> {/* لیست کارت‌های اقلام */}
{items.map((it) => ( <div data-field="line_items" className="flex flex-col gap-3">
<div key={it.tempId} className="item-row wizard-item-row items-center"> {items.map((it, idx) => (
<Combobox <div
value={it.cat} key={it.tempId}
onChange={(x) => patch(it.tempId, { cat: x.label })} className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
items={allMaterialNames} >
allowAdd {/* سربرگ قلم */}
addSuffix="به عنوان متریال جدید" <div className="flex items-center justify-between border-b border-line pb-2">
placeholder="متریال" <div className="flex items-center gap-1.5 min-w-0">
onInputChange={(v) => patch(it.tempId, { cat: v })} <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
onBlur={() => rememberField("i-cat", it)} {faNum(idx + 1)}
inputClassName="!text-[12px] !h-9" </span>
className="min-w-0" <span className="text-xs font-bold text-ink truncate">
/> {it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
<Combobox </span>
value={it.brand} </div>
onChange={(x) => patch(it.tempId, { brand: x.label })} {items.length > 1 && (
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)} <button
allowAdd type="button"
addSuffix="به عنوان برند جدید" className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
placeholder="برند" title="حذف قلم"
onInputChange={(v) => patch(it.tempId, { brand: v })} onClick={() => remove(it.tempId)}
onBlur={() => rememberField("i-brand", it)} >
inputClassName="!text-[12px] !h-9" <Trash2 className="h-3.5 w-3.5" />
className="min-w-0" <span className="text-[11px]">حذف</span>
/> </button>
<MultiSelectCombobox )}
values={it.specs} </div>
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه"
className="min-w-0"
/>
<NumberInput
value={it.qty}
onChange={(qty) => {
patch(it.tempId, {
qty,
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
});
}}
placeholder="تعداد"
showWords
/>
<Combobox
value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد"
onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
const name = v.trim();
if (!name) return;
void apiV26.catalog
.createUnit(name)
.then(() => {
setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
);
})
.catch(() => {});
}}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
});
}}
placeholder="قیمت واحد"
showWords
maxDecimals={2}
/>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
});
}}
placeholder="قیمت کل"
showWords
/>
{/* دکمه حذف مدرن با آیکون سطل زباله */} {/* ردیف ۱: متریال و برند */}
<button <div className="grid grid-cols-2 gap-2.5">
type="button" <div className="flex flex-col gap-1 min-w-0">
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors" <label className="text-[11px] font-semibold text-muted text-right">متریال</label>
title="حذف قلم" <Combobox
onClick={() => remove(it.tempId)} value={it.cat}
> onChange={(x) => patch(it.tempId, { cat: x.label })}
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> items={allMaterialNames}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> allowAdd
</svg> addSuffix="به عنوان متریال جدید"
</button> placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
<Combobox
value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })}
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۲: مشخصه فنی */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
className="min-w-0"
/>
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
<NumberInput
value={it.qty}
onChange={(qty) => {
patch(it.tempId, {
qty,
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
});
}}
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
<Combobox
value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد (شاخه، متر، کیلو...)"
onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
const name = v.trim();
if (!name) return;
void apiV26.catalog
.createUnit(name)
.then(() => {
setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
);
})
.catch(() => {});
}}
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
});
}}
placeholder="قیمت هر واحد"
showWords
className="!text-right text-right"
/>
</div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
});
}}
placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
/>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
{/* دکمه افزودن قلم با استایل برجسته و خط‌چین */} {/* دکمه افزودن قلم جدید */}
<button <button
type="button" type="button"
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150" className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
onClick={add} onClick={add}
> >
<span className="text-base font-bold leading-none">+</span> <Plus className="h-4 w-4" />
<span>افزودن قلم جدید</span> <span>افزودن قلم جدید</span>
</button> </button>
</div> </div>
+33 -15
View File
@@ -9,40 +9,58 @@ export interface StepperProps {
onGoto: (step: number) => void; onGoto: (step: number) => void;
} }
const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"]; const STEPS = [
{ id: 1, label: "تأمین‌کننده" },
{ id: 2, label: "اقلام" },
{ id: 3, label: "شرایط پرداخت" },
{ id: 4, label: "پروژه" },
{ id: 5, label: "بازبینی و ثبت" },
];
// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است // نوار ۵ مرحله‌ای ویزارد — واکنش‌گرا و ضدشکستگی در موبایل
// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی
export function Stepper({ current, completedSteps, onGoto }: StepperProps) { export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
return ( return (
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm"> <div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
{STEPS.map((label, i) => { {STEPS.map(({ id: step, label }) => {
const step = i + 1;
const isCurrent = step === current; const isCurrent = step === current;
const isComplete = completedSteps.has(step); const isComplete = completedSteps.has(step);
return ( return (
<button <button
key={step} key={step}
type="button" type="button"
className={cn( className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors", "flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
// در موبایل مرحله جاری فضا می‌گیرد و بقیه جمع‌وجور می‌مانند تا سطر نشکند
isCurrent isCurrent
? "bg-[#2563eb] font-bold text-white" ? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
: isComplete : "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
? "bg-green-bg font-medium text-green" isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
: "hover:bg-slate-bg",
)} )}
onClick={() => onGoto(step)} onClick={() => onGoto(step)}
> >
<span <span
className={cn( className={cn(
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold", "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted", isCurrent
? "bg-white/20 text-white"
: isComplete
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
: "bg-muted/15 text-muted",
)} )}
> >
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)} {isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
</span>
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نام‌ها نمایش دارند */}
<span
className={cn(
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
!isCurrent && "hidden md:inline",
)}
>
{label}
</span> </span>
{label}
</button> </button>
); );
})} })}
+23 -40
View File
@@ -127,11 +127,9 @@ export function WizardPage() {
goToStep(currentStep + 1); goToStep(currentStep + 1);
}; };
const rememberMaterial = (_rec: MaterialRec) => { const rememberMaterial = (_rec: MaterialRec) => {};
// رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
};
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ---------- // ---------- پروژه‌ها ----------
const projectNames = useMemo(() => { const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name)); const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => { quotes.forEach((q) => {
@@ -159,7 +157,7 @@ export function WizardPage() {
}); });
}; };
// ---------- خلاصه‌ی مرحله ۵ ---------- // ---------- خلاصه مرحله ۵ ----------
const review = useMemo(() => { const review = useMemo(() => {
let grandTotal = 0; let grandTotal = 0;
items.forEach((it) => { items.forEach((it) => {
@@ -202,23 +200,14 @@ export function WizardPage() {
const combined = `${field} ${msg}`.toLowerCase(); const combined = `${field} ${msg}`.toLowerCase();
// ۱. تأمین‌کننده
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" }; if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
// ۲. اقلام / متریال / واحد / تعداد / قیمت
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) { if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
return { step: 2, field: field || "line_items" }; return { step: 2, field: field || "line_items" };
} }
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) { if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
return { step: 3, field: field || "termsheets" }; return { step: 3, field: field || "termsheets" };
} }
// ۴. پروژه
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" }; if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
// ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" }; 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 (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" }; if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" };
@@ -291,10 +280,6 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید"; const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName)); let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) { if (!material) {
const matched = matchMaterial( const matched = matchMaterial(
@@ -317,7 +302,6 @@ export function WizardPage() {
brand, brand,
specs, specs,
base_unit: unit || "عدد", base_unit: unit || "عدد",
supplier_category: "",
}); });
materialId = created.id; materialId = created.id;
} catch (err) { } catch (err) {
@@ -427,7 +411,7 @@ export function WizardPage() {
}; };
return ( return (
<div className="min-h-screen"> <div className="min-h-screen pb-10">
<Header <Header
title="افزودن پیشنهاد جدید" title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید" sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
@@ -435,10 +419,12 @@ export function WizardPage() {
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
/> />
<PageLayout> <PageLayout>
{/* نوار مراحل استپر */}
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} /> <Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
{currentStep === 1 && ( {currentStep === 1 && (
<div data-step="1" 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-3.5 sm:p-5 shadow-xs">
<Step1TopicSupplier <Step1TopicSupplier
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
onTopicSelect={(t) => { onTopicSelect={(t) => {
@@ -462,12 +448,12 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div data-step="2" 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-3.5 sm:p-5 shadow-xs">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} /> <Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div> </div>
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<div data-step="3" 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-3.5 sm:p-5 shadow-xs">
<Step3Termsheets <Step3Termsheets
cards={cards} cards={cards}
onCardsChange={setCards} onCardsChange={setCards}
@@ -478,7 +464,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div data-step="4" 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-3.5 sm:p-5 shadow-xs">
<Step4Projects <Step4Projects
items={items} items={items}
projects={projectNames} projects={projectNames}
@@ -492,7 +478,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 5 && ( {currentStep === 5 && (
<div data-step="5" 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-3.5 sm:p-5 shadow-xs">
<Step5Review <Step5Review
quoteDate={quoteDate} quoteDate={quoteDate}
onQuoteDate={setQuoteDate} onQuoteDate={setQuoteDate}
@@ -507,15 +493,14 @@ export function WizardPage() {
</div> </div>
)} )}
{/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */} {/* پاورقی کنترل مراحل */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm"> <div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
<div> <div>
{currentStep > 1 && ( {currentStep > 1 && (
<button <button
type="button" type="button"
onClick={() => goToStep(currentStep - 1)} onClick={() => goToStep(currentStep - 1)}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer" className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
<span>قبلی</span> <span>قبلی</span>
@@ -523,21 +508,19 @@ export function WizardPage() {
)} )}
</div> </div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */} <div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-3">
<Link <Link
to="/" to="/"
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline" className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
> >
انصراف انصراف
</Link> </Link>
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{currentStep < TOTAL_STEPS && ( {currentStep < TOTAL_STEPS && (
<button <button
type="button" type="button"
onClick={next} onClick={next}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer" className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
> >
<span>بعدی</span> <span>بعدی</span>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -548,7 +531,7 @@ export function WizardPage() {
<button <button
type="button" type="button"
onClick={() => void save()} onClick={() => void save()}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer" className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
> >
<span>ذخیره پیشنهاد</span> <span>ذخیره پیشنهاد</span>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
@@ -557,21 +540,21 @@ export function WizardPage() {
</div> </div>
</div> </div>
{/* دیالوگ ذخیره با فیلدهای خالی */} {/* دیالوگ فیلدهای خالی */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>فیلدهای خالی</DialogTitle> <DialogTitle>فیلدهای خالی</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="text-[13.5px] leading-7"> <div className="text-[13.5px] leading-7 text-gray-700">
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟ هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
</div> </div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
{emptyFields.map((f) => ( {emptyFields.map((f) => (
<button <button
key={f.step} key={f.step}
type="button" type="button"
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark" className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
onClick={() => { onClick={() => {
setConfirmOpen(false); setConfirmOpen(false);
goToStep(f.step); goToStep(f.step);
@@ -598,7 +581,7 @@ export function WizardPage() {
goToStep(emptyFields[0]?.step ?? 1); goToStep(emptyFields[0]?.step ?? 1);
}} }}
> >
تکمیل فیلد ها تکمیل فیلدها
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
-29
View File
@@ -7,7 +7,6 @@
import { create } from "zustand"; import { create } from "zustand";
import type { import type {
CategoryUnit,
Material, Material,
Project, Project,
Quote, Quote,
@@ -15,7 +14,6 @@ import type {
Store, Store,
Supplier, Supplier,
Topic, Topic,
UnitConversion,
} from "../lib/types"; } from "../lib/types";
interface AppStore { interface AppStore {
@@ -24,8 +22,6 @@ interface AppStore {
materials: Material[]; materials: Material[];
projects: Project[]; projects: Project[];
quotes: Quote[]; quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories
@@ -45,9 +41,6 @@ interface AppStore {
deleteSupplier: (id: string) => void; deleteSupplier: (id: string) => void;
addMaterial: (m: Material) => void; addMaterial: (m: Material) => void;
addProject: (p: Project) => void; addProject: (p: Project) => void;
setCategoryUnit: (cu: CategoryUnit) => void;
setConversion: (c: UnitConversion) => void;
deleteConversion: (id: string) => void;
setStandardConversion: (sc: StandardConversion) => void; setStandardConversion: (sc: StandardConversion) => void;
deleteStandardConversion: (id: string) => void; deleteStandardConversion: (id: string) => void;
@@ -62,8 +55,6 @@ export const useAppStore = create<AppStore>()((set) => ({
materials: [], materials: [],
projects: [], projects: [],
quotes: [], quotes: [],
categoryUnits: [],
unitConversions: [],
standardConversions: [], standardConversions: [],
topics: [], topics: [],
@@ -77,8 +68,6 @@ export const useAppStore = create<AppStore>()((set) => ({
materials: store.materials, materials: store.materials,
projects: store.projects, projects: store.projects,
quotes: store.quotes, quotes: store.quotes,
categoryUnits: store.categoryUnits,
unitConversions: store.unitConversions,
standardConversions: store.standardConversions, standardConversions: store.standardConversions,
topics: store.topics, topics: store.topics,
}), }),
@@ -96,24 +85,6 @@ export const useAppStore = create<AppStore>()((set) => ({
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })), deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })), addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })), addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
setCategoryUnit: (cu) =>
set((s) => {
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
const categoryUnits = [...s.categoryUnits];
if (idx >= 0) categoryUnits[idx] = cu;
else categoryUnits.push(cu);
return { categoryUnits };
}),
setConversion: (c) =>
set((s) => {
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
const unitConversions = [...s.unitConversions];
if (idx >= 0) unitConversions[idx] = c;
else unitConversions.push(c);
return { unitConversions };
}),
deleteConversion: (id) =>
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
setStandardConversion: (sc) => setStandardConversion: (sc) =>
set((s) => { set((s) => {
const idx = s.standardConversions.findIndex((x) => x.id === sc.id); const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
+1
View File
@@ -455,6 +455,7 @@ CREATE TABLE inventory_transactions (
physical_qty NUMERIC(18,6), physical_qty NUMERIC(18,6),
transfer_pair_id UUID, transfer_pair_id UUID,
performed_by TEXT, performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(), transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT, note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()