Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9ef408ff | ||
|
|
43ff048899 |
@@ -119,6 +119,7 @@ def _fa_validation_msg(msg: str) -> str:
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
||||||
|
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
||||||
first = exc.errors()[0]
|
first = exc.errors()[0]
|
||||||
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
||||||
field = ".".join(loc) if loc else None
|
field = ".".join(loc) if loc else None
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async def update_line_item(
|
|||||||
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
||||||
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
||||||
|
|
||||||
|
old_material_id = li.material_id
|
||||||
if payload.material_id is not None:
|
if payload.material_id is not None:
|
||||||
li.material_id = payload.material_id
|
li.material_id = payload.material_id
|
||||||
# v2.5: keep the supplier<->material catalog edge in sync.
|
# v2.5: keep the supplier<->material catalog edge in sync.
|
||||||
@@ -121,6 +122,11 @@ async def update_line_item(
|
|||||||
if payload.notes is not None:
|
if payload.notes is not None:
|
||||||
li.notes = payload.notes
|
li.notes = payload.notes
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if payload.material_id is not None and payload.material_id != old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +139,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
|
|||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
||||||
if decision is not None and decision.status == "chosen":
|
if decision is not None and decision.status == "chosen":
|
||||||
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
||||||
|
old_material_id = li.material_id
|
||||||
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": {"id": str(line_item_id), "deleted": True}}
|
return {"data": {"id": str(line_item_id), "deleted": True}}
|
||||||
|
|
||||||
|
|||||||
@@ -290,12 +290,78 @@ async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
|||||||
return (count or 0) > 0
|
return (count or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
|
||||||
|
"""Check candidate material_ids and purge any that have zero references.
|
||||||
|
|
||||||
|
A material is considered an orphan if:
|
||||||
|
1. It is not referenced by ANY line_items across all quotes.
|
||||||
|
2. It is not referenced by ANY inventory stock items in warehouses.
|
||||||
|
|
||||||
|
If count == 0:
|
||||||
|
- Auxiliary records in supplier_materials, brand_materials, material_units,
|
||||||
|
and material_attribute_values are cleaned up.
|
||||||
|
- The material row is deleted from the central materials table.
|
||||||
|
|
||||||
|
Returns the list of purged material UUIDs.
|
||||||
|
"""
|
||||||
|
purged: list[UUID] = []
|
||||||
|
unique_ids = {m for m in material_ids if m is not None}
|
||||||
|
if not unique_ids:
|
||||||
|
return purged
|
||||||
|
|
||||||
|
from app.models.catalog import (
|
||||||
|
BrandMaterial,
|
||||||
|
Material,
|
||||||
|
MaterialAttributeValue,
|
||||||
|
MaterialUnit,
|
||||||
|
)
|
||||||
|
from app.models.inventory import InventoryItem
|
||||||
|
from app.models.procurement import LineItem, SupplierMaterial
|
||||||
|
|
||||||
|
for mat_id in unique_ids:
|
||||||
|
# Check active usage in line_items across the whole system
|
||||||
|
line_item_count = await db.scalar(
|
||||||
|
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (line_item_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check active usage in warehouse inventory
|
||||||
|
inv_count = await db.scalar(
|
||||||
|
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (inv_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
|
||||||
|
await db.execute(
|
||||||
|
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(Material).where(Material.id == mat_id)
|
||||||
|
)
|
||||||
|
purged.append(mat_id)
|
||||||
|
logger.info("Purged orphan material %s from central catalog", mat_id)
|
||||||
|
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||||
|
|
||||||
Only allowed while the quote is still offered and has no warehouse receipts.
|
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||||
|
Automatically purges any materials detached during this replacement if they become orphans.
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
@@ -309,6 +375,11 @@ async def replace_quote_children(
|
|||||||
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
field="status",
|
field="status",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
|
||||||
|
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
|
||||||
|
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
|
||||||
|
|
||||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
@@ -317,10 +388,21 @@ async def replace_quote_children(
|
|||||||
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 2. Identify detached materials: previous materials no longer present in the updated line items
|
||||||
|
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
|
||||||
|
detached_material_ids = old_material_ids - new_material_ids
|
||||||
|
|
||||||
|
# 3. Clean up any detached materials that are now orphans (0 usage)
|
||||||
|
if detached_material_ids:
|
||||||
|
await cleanup_orphan_materials(db, detached_material_ids)
|
||||||
|
|
||||||
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||||
current = quote.status
|
current = quote.status
|
||||||
@@ -346,7 +428,7 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(ItemDecision)
|
update(ItemDecision)
|
||||||
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
|
.where(ItemDecision.line_item_id.in_(li_ids))
|
||||||
.values(
|
.values(
|
||||||
status="pending",
|
status="pending",
|
||||||
decided_at=None,
|
decided_at=None,
|
||||||
@@ -524,21 +606,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
decision.adjusted_notes = decision_in.adjusted_notes
|
decision.adjusted_notes = decision_in.adjusted_notes
|
||||||
decision.decided_at = utcnow()
|
decision.decided_at = utcnow()
|
||||||
|
|
||||||
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
|
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
|
||||||
for entry in payload.item_projects:
|
|
||||||
if entry.line_item_id not in li_ids:
|
|
||||||
raise NotFoundError(
|
|
||||||
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(LineItemProjects)
|
|
||||||
.where(LineItemProjects.line_item_id == entry.line_item_id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
for pid in entry.project_ids:
|
|
||||||
if await db.get(Project, pid) is None:
|
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
|
|
||||||
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
|
|
||||||
|
|
||||||
# termsheets referenced by at least one chosen item -> accepted
|
# termsheets referenced by at least one chosen item -> accepted
|
||||||
if chosen_ts_ids:
|
if chosen_ts_ids:
|
||||||
@@ -735,20 +803,36 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||||
|
|
||||||
# clean up attachments in MinIO before cascade
|
# collect candidate material_ids for orphan cleanup before deleting quote
|
||||||
|
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
|
||||||
|
|
||||||
|
# collect attachment keys before deleting quote row
|
||||||
from app.models.files import QuoteAttachment
|
from app.models.files import QuoteAttachment
|
||||||
from app.services import storage
|
from app.services import storage
|
||||||
|
|
||||||
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||||
for att in attachments:
|
att_keys = [att.object_key for att in attachments]
|
||||||
try:
|
quote_prefix = f"{quote.id}/"
|
||||||
await storage.remove_object(att.object_key)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
|
|
||||||
|
|
||||||
await db.delete(quote)
|
await db.delete(quote)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Purge any materials that no longer have any references in the system
|
||||||
|
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
|
||||||
|
|
||||||
|
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
|
||||||
|
for key in att_keys:
|
||||||
|
try:
|
||||||
|
await storage.remove_object(key)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
|
||||||
|
try:
|
||||||
|
await storage.remove_prefix(quote_prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(quote.id),
|
"id": str(quote_id),
|
||||||
"deleted": True,
|
"deleted": True,
|
||||||
"cascaded": {
|
"cascaded": {
|
||||||
"line_items": len(line_items),
|
"line_items": len(line_items),
|
||||||
@@ -757,10 +841,13 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
"decisions": len(decisions),
|
"decisions": len(decisions),
|
||||||
"receipts": len(receipts),
|
"receipts": len(receipts),
|
||||||
"attachments": len(attachments),
|
"attachments": len(attachments),
|
||||||
|
"purged_orphan_materials": len(purged_material_ids),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Statistics
|
# Statistics
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
|
|||||||
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_prefix(prefix: str) -> None:
|
||||||
|
"""Delete all objects matching a prefix in the bucket (best-effort)."""
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
def _sync_remove_prefix():
|
||||||
|
try:
|
||||||
|
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
|
||||||
|
for obj in objects:
|
||||||
|
try:
|
||||||
|
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_sync_remove_prefix)
|
||||||
|
|
||||||
|
|||||||
+51
-14
@@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
|
container_name: procurement-postgres
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: procurement
|
POSTGRES_DB: procurement
|
||||||
@@ -8,7 +9,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "5435:5432"
|
- "5433:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- postgres_data:/var/lib/postgresql
|
||||||
@@ -23,13 +24,24 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
|
||||||
|
# App is served on http://localhost (port 80) instead of :3000.
|
||||||
|
# Production: see docker-compose.prod.yml (real domains + HTTPS via
|
||||||
|
# Let's Encrypt, HTTP→HTTPS redirect).
|
||||||
|
# ==========================================================================
|
||||||
traefik:
|
traefik:
|
||||||
|
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
|
||||||
|
# ("Error response from daemon" — Docker API version mismatch).
|
||||||
image: traefik:v3.6
|
image: traefik:v3.6
|
||||||
|
container_name: procurement-traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
- --providers.docker.network=erp_production_network
|
# Containers are on several networks; Traefik must use the dedicated
|
||||||
|
# proxy network IP (otherwise it may pick the default-network IP → 504).
|
||||||
|
- --providers.docker.network=traefik_network
|
||||||
- --providers.file.filename=/etc/traefik/dynamic.yml
|
- --providers.file.filename=/etc/traefik/dynamic.yml
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
- --entrypoints.websecure.address=:443
|
- --entrypoints.websecure.address=:443
|
||||||
@@ -39,9 +51,11 @@ services:
|
|||||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||||
- --log.level=INFO
|
- --log.level=INFO
|
||||||
ports:
|
ports:
|
||||||
- "8084:80"
|
- "80:80"
|
||||||
- "8445:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
|
# NOTE: mounted read-write — Docker Desktop rejects read-only
|
||||||
|
# /var/run/docker.sock mounts ("Error response from daemon").
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
- traefik_certs:/certs
|
- traefik_certs:/certs
|
||||||
@@ -50,6 +64,7 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
|
container_name: procurement-minio
|
||||||
|
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
|
|
||||||
@@ -58,18 +73,20 @@ services:
|
|||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "9004:9000" # S3 API
|
- "9000:9000" # S3 API
|
||||||
- "9005:9001" # Web console
|
- "9001:9001" # Web console
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
# S3 API
|
||||||
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
|
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
|
||||||
- traefik.http.routers.minio-api.entrypoints=web
|
- traefik.http.routers.minio-api.entrypoints=web
|
||||||
- traefik.http.routers.minio-api.service=minio-api-svc
|
- traefik.http.routers.minio-api.service=minio-api-svc
|
||||||
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
|
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
|
||||||
|
# Web console
|
||||||
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
|
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
|
||||||
- traefik.http.routers.minio-console.entrypoints=web
|
- traefik.http.routers.minio-console.entrypoints=web
|
||||||
- traefik.http.routers.minio-console.service=minio-console-svc
|
- traefik.http.routers.minio-console.service=minio-console-svc
|
||||||
@@ -81,8 +98,11 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MinIO Client (mc): waits for the server, creates the bucket, exits.
|
||||||
|
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc:latest
|
||||||
|
container_name: procurement-minio-init
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
@@ -105,8 +125,20 @@ services:
|
|||||||
|
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# v2.6 — Casdoor (OIDC identity provider)
|
||||||
|
# Console: http://localhost:8080 (admin / 123)
|
||||||
|
# Application "inventory-app" (org "inventory", client inventory-app-client)
|
||||||
|
# is created by backend/tools/seed_casdoor.py — run it once after the
|
||||||
|
# first boot:
|
||||||
|
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||||
|
#
|
||||||
|
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
|
||||||
|
# created by sql/02-casdoor-db.sql on a fresh volume).
|
||||||
|
# ==========================================================================
|
||||||
casdoor:
|
casdoor:
|
||||||
image: casbin/casdoor:latest
|
image: casbin/casdoor:latest
|
||||||
|
container_name: procurement-casdoor
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -115,9 +147,9 @@ services:
|
|||||||
driverName: postgres
|
driverName: postgres
|
||||||
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
|
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
|
||||||
dbName: casdoor
|
dbName: casdoor
|
||||||
origin: "http://localhost:8083"
|
origin: "http://localhost:8080"
|
||||||
ports:
|
ports:
|
||||||
- "8083:8000"
|
- "8080:8000"
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -135,6 +167,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
container_name: procurement-api
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||||
@@ -143,18 +176,21 @@ services:
|
|||||||
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
||||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||||
PROCUREMENT_MINIO_SECURE: "false"
|
PROCUREMENT_MINIO_SECURE: "false"
|
||||||
|
# Casdoor: endpoint is the in-network service URL; the issuer must match
|
||||||
|
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
||||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||||
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083
|
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080
|
||||||
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
||||||
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
||||||
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
||||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||||
|
# Console admin used by POST /users/sync (identity mirroring).
|
||||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -178,10 +214,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
|
# Developer Mode panel on the login page — set false for production.
|
||||||
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
|
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
|
||||||
|
container_name: procurement-frontend
|
||||||
ports:
|
|
||||||
- "8085:80"
|
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -199,8 +234,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||||
|
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||||
traefik_network:
|
traefik_network:
|
||||||
name: erp_production_network
|
name: traefik_network
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
|
||||||
import type { Attachment } from "../lib/types";
|
import type { Attachment } from "../lib/types";
|
||||||
|
|
||||||
export interface FileListProps {
|
export interface FileListProps {
|
||||||
@@ -15,18 +14,9 @@ export interface FileListProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImageFile = (name: string = "") => {
|
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
||||||
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPdfFile = (name: string = "") => {
|
|
||||||
return /\.pdf$/i.test(name.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
|
||||||
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
|
||||||
|
|
||||||
const remove = (i: number) => {
|
const remove = (i: number) => {
|
||||||
const removed = files[i];
|
const removed = files[i];
|
||||||
@@ -49,94 +39,22 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
|
|||||||
if (added.length) onChange([...files, ...added]);
|
if (added.length) onChange([...files, ...added]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenPreview = (inv: Attachment) => {
|
|
||||||
const url = urlOf?.(inv);
|
|
||||||
if (!url) {
|
|
||||||
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewItem({ url, name: inv.name });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
{!files.length && (
|
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
||||||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
|
{files.map((inv, i) => (
|
||||||
هنوز فاکتور یا سندی بارگذاری نشده است.
|
<div key={i} className="inv-row">
|
||||||
</div>
|
<span className="inv-name">📎 {inv.name}</span>
|
||||||
)}
|
{urlOf?.(inv) && (
|
||||||
|
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
|
||||||
{files.length > 0 && (
|
↓
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{files.map((inv, i) => {
|
|
||||||
const url = urlOf?.(inv);
|
|
||||||
const isImg = isImageFile(inv.name);
|
|
||||||
const isPdf = isPdfFile(inv.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={inv.id || i}
|
|
||||||
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
|
|
||||||
>
|
|
||||||
{/* نام و آیکون فایل — با قابلیت کلیک برای پیشنمایش */}
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 min-w-0 flex-1 ${url ? "cursor-pointer group" : ""}`}
|
|
||||||
onClick={() => url && handleOpenPreview(inv)}
|
|
||||||
title={url ? "مشاهده پیشنمایش" : inv.name}
|
|
||||||
>
|
|
||||||
{isImg ? (
|
|
||||||
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
|
||||||
) : isPdf ? (
|
|
||||||
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
|
||||||
) : (
|
|
||||||
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
|
|
||||||
)}
|
|
||||||
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
|
|
||||||
{inv.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* دکمههای عملیات: پیشنمایش، دانلود مستقیم، حذف */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0 me-1">
|
|
||||||
{url && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenPreview(inv)}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
|
||||||
title="مشاهده فاکتور"
|
|
||||||
>
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
download={inv.name}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
|
||||||
title="دانلود فایل"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
</a>
|
</a>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
||||||
<button
|
×
|
||||||
type="button"
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
|
||||||
title="حذف فایل"
|
|
||||||
onClick={() => remove(i)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -146,105 +64,10 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
||||||
type="button"
|
+ {label}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<span>+</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* پنجره مودال پیشنمایش (Preview Modal) */}
|
|
||||||
{previewItem && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
|
|
||||||
onClick={() => setPreviewItem(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* هدر پیشنمایش */}
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
{isImageFile(previewItem.name) ? (
|
|
||||||
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
|
||||||
) : (
|
|
||||||
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">
|
|
||||||
{previewItem.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<a
|
|
||||||
href={previewItem.url}
|
|
||||||
download={previewItem.name}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
|
||||||
title="دانلود فاکتور"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
<span className="hidden xs:inline">دانلود فاکتور</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={previewItem.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
|
||||||
title="باز کردن در برگه جدید"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPreviewItem(null)}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
|
|
||||||
title="بستن"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* بدنه نمایش فایل */}
|
|
||||||
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
|
|
||||||
{isImageFile(previewItem.name) ? (
|
|
||||||
<img
|
|
||||||
src={previewItem.url}
|
|
||||||
alt={previewItem.name}
|
|
||||||
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
|
||||||
/>
|
|
||||||
) : isPdfFile(previewItem.name) ? (
|
|
||||||
<iframe
|
|
||||||
src={previewItem.url}
|
|
||||||
title={previewItem.name}
|
|
||||||
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
|
|
||||||
<FileText className="h-12 w-12 text-gray-400" />
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
امکان نمایش پیشنمایش زنده برای این فرمت فایل وجود ندارد.
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={previewItem.url}
|
|
||||||
download={previewItem.name}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
دانلود فایل
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
jalaliStrToIso,
|
jalaliStrToIso,
|
||||||
jalaliToGregorian,
|
jalaliToGregorian,
|
||||||
MONTHS,
|
MONTHS,
|
||||||
|
toAsciiDigits,
|
||||||
toFa,
|
toFa,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
} from "../lib/jalali";
|
} from "../lib/jalali";
|
||||||
@@ -29,7 +30,7 @@ function todayJalali(): [number, number, number] {
|
|||||||
|
|
||||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const gy = parseInt(m[1], 10);
|
const gy = parseInt(m[1], 10);
|
||||||
const gm = parseInt(m[2], 10);
|
const gm = parseInt(m[2], 10);
|
||||||
@@ -78,6 +79,10 @@ export function JalaliDatePicker({
|
|||||||
}: JalaliDatePickerProps) {
|
}: JalaliDatePickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||||
|
const initialTime = timeOf(value);
|
||||||
|
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||||
|
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
|
||||||
|
|
||||||
const [text, setText] = useState<string>(() => {
|
const [text, setText] = useState<string>(() => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const j = isoToJalaliStr(value);
|
const j = isoToJalaliStr(value);
|
||||||
@@ -92,19 +97,28 @@ export function JalaliDatePicker({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setText("");
|
setText("");
|
||||||
|
const now = nowTime();
|
||||||
|
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const j = isoToJalaliStr(value);
|
const j = isoToJalaliStr(value);
|
||||||
|
const t = timeOf(value);
|
||||||
if (!withTime) {
|
if (!withTime) {
|
||||||
setText(j);
|
setText(j);
|
||||||
} else {
|
} else {
|
||||||
const d = new Date(value);
|
const hh = String(t.h).padStart(2, "0");
|
||||||
if (Number.isNaN(d.getTime())) {
|
const mm = String(t.m).padStart(2, "0");
|
||||||
setText(j);
|
|
||||||
} else {
|
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
||||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
|
||||||
|
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
|
||||||
|
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
if (curH !== t.h) {
|
||||||
|
setHourText(faNum(hh));
|
||||||
|
}
|
||||||
|
const curM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
if (curM !== t.m) {
|
||||||
|
setMinuteText(faNum(mm));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const jParts = isoToJalali(value);
|
const jParts = isoToJalali(value);
|
||||||
@@ -116,14 +130,13 @@ export function JalaliDatePicker({
|
|||||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||||
function selectedLocalDate(): string | null {
|
function selectedLocalDate(): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
const selectedIso = selectedLocalDate();
|
const selectedIso = selectedLocalDate();
|
||||||
const time = timeOf(value);
|
|
||||||
|
|
||||||
const grid = useMemo(() => {
|
const grid = useMemo(() => {
|
||||||
const [jy, jm] = curJ;
|
const [jy, jm] = curJ;
|
||||||
@@ -163,36 +176,108 @@ export function JalaliDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||||
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
|
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
|
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}`
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTimePart = (part: "h" | "m", raw: string) => {
|
const handleHourChange = (raw: string) => {
|
||||||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
if (Number.isNaN(n)) return;
|
if (digits.length === 3) {
|
||||||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
digits = digits.slice(-1);
|
||||||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
setHourText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(digits, 10);
|
||||||
|
const clamped = Math.min(23, Math.max(0, n));
|
||||||
|
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
|
||||||
|
setHourText(faNum(textToShow));
|
||||||
|
|
||||||
const [jy, jm, jd] = curJ;
|
const [jy, jm, jd] = curJ;
|
||||||
onChange(buildIso(jy, jm, jd, h, m));
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
// بهروزرسانی نمایش
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHourBlur = () => {
|
||||||
|
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
|
||||||
|
const n = digits ? parseInt(digits, 10) : 0;
|
||||||
|
const clamped = Math.min(23, Math.max(0, n));
|
||||||
|
setHourText(faNum(String(clamped).padStart(2, "0")));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
|
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteChange = (raw: string) => {
|
||||||
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
|
if (digits.length === 3) {
|
||||||
|
digits = digits.slice(-1);
|
||||||
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
setMinuteText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(digits, 10);
|
||||||
|
const clamped = Math.min(59, Math.max(0, n));
|
||||||
|
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
|
||||||
|
setMinuteText(faNum(textToShow));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteBlur = () => {
|
||||||
|
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
|
||||||
|
const n = digits ? parseInt(digits, 10) : 0;
|
||||||
|
const clamped = Math.min(59, Math.max(0, n));
|
||||||
|
setMinuteText(faNum(String(clamped).padStart(2, "0")));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickToday = () => {
|
const pickToday = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [jy, jm, jd] = todayJalali();
|
const [jy, jm, jd] = todayJalali();
|
||||||
|
const nh = now.getHours();
|
||||||
|
const nm = now.getMinutes();
|
||||||
|
setHourText(faNum(String(nh).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(nm).padStart(2, "0")));
|
||||||
const val = withTime
|
const val = withTime
|
||||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
? buildIso(jy, jm, jd, nh, nm)
|
||||||
: buildDateOnly(jy, jm, jd);
|
: buildDateOnly(jy, jm, jd);
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
withTime
|
withTime
|
||||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}`
|
||||||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -201,17 +286,24 @@ export function JalaliDatePicker({
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
onChange(null);
|
onChange(null);
|
||||||
setText("");
|
setText("");
|
||||||
|
const now = nowTime();
|
||||||
|
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onInputChange = (v: string) => {
|
const onInputChange = (v: string) => {
|
||||||
setText(v);
|
setText(v);
|
||||||
|
if (!v.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const iso = jalaliStrToIso(v);
|
const iso = jalaliStrToIso(v);
|
||||||
if (iso) {
|
if (iso) {
|
||||||
if (!withTime) {
|
if (!withTime) {
|
||||||
const nums = String(v)
|
const nums = String(v)
|
||||||
.split(/[/\-.]/)
|
.split(/[/\-.]/)
|
||||||
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
|
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||||
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||||
@@ -226,6 +318,51 @@ export function JalaliDatePicker({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const iso = jalaliStrToIso(text);
|
||||||
|
if (iso) {
|
||||||
|
if (!withTime) {
|
||||||
|
const nums = String(text)
|
||||||
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||||
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
|
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
onChange(dOnly);
|
||||||
|
setText(isoToJalaliStr(dOnly));
|
||||||
|
} else {
|
||||||
|
onChange(iso.slice(0, 10));
|
||||||
|
setText(isoToJalaliStr(iso));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(iso);
|
||||||
|
const j = isoToJalaliStr(iso);
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (value) {
|
||||||
|
const j = isoToJalaliStr(value);
|
||||||
|
if (!withTime) {
|
||||||
|
setText(j);
|
||||||
|
} else {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
@@ -238,6 +375,7 @@ export function JalaliDatePicker({
|
|||||||
onFocus={() => !disabled && setOpen(true)}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onClick={() => !disabled && setOpen(true)}
|
onClick={() => !disabled && setOpen(true)}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
/>
|
/>
|
||||||
{open && !disabled && (
|
{open && !disabled && (
|
||||||
<div
|
<div
|
||||||
@@ -248,6 +386,7 @@ export function JalaliDatePicker({
|
|||||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* 1. Header: Month / Year Navigation */}
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||||
«
|
«
|
||||||
@@ -265,6 +404,8 @@ export function JalaliDatePicker({
|
|||||||
»
|
»
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Day of Week Header & Days Grid */}
|
||||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||||
{WEEKDAYS.map((w) => (
|
{WEEKDAYS.map((w) => (
|
||||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||||
@@ -291,33 +432,40 @@ export function JalaliDatePicker({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||||
{withTime && (
|
{withTime && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={faNum(String(time.h).padStart(2, "0"))}
|
value={minuteText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={2}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setTimePart("h", e.target.value)}
|
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||||
|
onBlur={handleMinuteBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span className="text-muted">:</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-muted">:</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={faNum(String(time.m).padStart(2, "0"))}
|
value={hourText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={2}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setTimePart("m", e.target.value)}
|
onChange={(e) => handleHourChange(e.target.value)}
|
||||||
|
onBlur={handleHourBlur}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* 4. Action Buttons Footer */}
|
||||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||||
امروز
|
امروز
|
||||||
|
|||||||
@@ -12,19 +12,15 @@ export interface MilestoneRowProps {
|
|||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 w-full my-2">
|
<div className="milestone-row">
|
||||||
{/* کادر انتخاب تاریخ */}
|
|
||||||
<div className="w-36 shrink-0">
|
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={milestone.when_iso}
|
value={milestone.when_iso}
|
||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
placeholder="تاریخ"
|
placeholder="تاریخ"
|
||||||
|
className="w-36 shrink-0"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="ms-amount-wrap">
|
||||||
|
|
||||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||||
@@ -33,15 +29,8 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه حذف */}
|
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||||
type="button"
|
|
||||||
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
|
|
||||||
title="حذف"
|
|
||||||
onClick={onRemove}
|
|
||||||
>
|
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
|
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
|
||||||
import { fmtNum } from "../lib/formatters";
|
import { fmtNum } from "../lib/formatters";
|
||||||
|
|
||||||
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
|
|
||||||
|
|
||||||
export interface StatsBarProps {
|
export interface StatsBarProps {
|
||||||
total: number;
|
total: number;
|
||||||
offered: number;
|
offered: number;
|
||||||
accepted: number;
|
accepted: number;
|
||||||
arrived: number;
|
avgLeadTime: number | null; // روز
|
||||||
activeFilter?: StatusFilter;
|
|
||||||
onFilterChange?: (filter: StatusFilter) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsBar({
|
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
||||||
total,
|
|
||||||
offered,
|
|
||||||
accepted,
|
|
||||||
arrived,
|
|
||||||
activeFilter = "all",
|
|
||||||
onFilterChange,
|
|
||||||
}: StatsBarProps) {
|
|
||||||
const stats: {
|
const stats: {
|
||||||
key: StatusFilter;
|
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -30,7 +18,6 @@ export function StatsBar({
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "all",
|
|
||||||
label: "کل پیشنهادها",
|
label: "کل پیشنهادها",
|
||||||
shortLabel: "کل",
|
shortLabel: "کل",
|
||||||
value: fmtNum(total),
|
value: fmtNum(total),
|
||||||
@@ -38,7 +25,6 @@ export function StatsBar({
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "offered",
|
|
||||||
label: "پیشنهاد باز",
|
label: "پیشنهاد باز",
|
||||||
shortLabel: "باز",
|
shortLabel: "باز",
|
||||||
value: fmtNum(offered),
|
value: fmtNum(offered),
|
||||||
@@ -46,7 +32,6 @@ export function StatsBar({
|
|||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "accepted",
|
|
||||||
label: "پذیرفتهشده",
|
label: "پذیرفتهشده",
|
||||||
shortLabel: "پذیرفته",
|
shortLabel: "پذیرفته",
|
||||||
value: fmtNum(accepted),
|
value: fmtNum(accepted),
|
||||||
@@ -54,41 +39,21 @@ export function StatsBar({
|
|||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "arrived",
|
label: "میانگین زمان تحویل",
|
||||||
label: "تحویلشده به کارگاه",
|
shortLabel: "میانگین تحویل",
|
||||||
shortLabel: "تحویل",
|
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
|
||||||
value: fmtNum(arrived),
|
unit: avgLeadTime != null ? "روز" : undefined,
|
||||||
color: "text-green",
|
color: "text-green",
|
||||||
icon: PackageCheck,
|
icon: Timer,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleClick = (key: StatusFilter) => {
|
|
||||||
if (!onFilterChange) return;
|
|
||||||
if (key === "all") {
|
|
||||||
onFilterChange("all");
|
|
||||||
} else {
|
|
||||||
onFilterChange(activeFilter === key ? "all" : key);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
||||||
{stats.map((s) => {
|
{stats.map((s) => (
|
||||||
const isClickable = Boolean(onFilterChange);
|
|
||||||
const isActive = activeFilter === s.key;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
onClick={() => handleClick(s.key)}
|
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
|
||||||
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
|
|
||||||
isClickable ? "cursor-pointer select-none" : ""
|
|
||||||
} ${
|
|
||||||
isActive
|
|
||||||
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
|
|
||||||
: "border-line bg-panel hover:border-accent/40"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* عنوان کارت */}
|
{/* عنوان کارت */}
|
||||||
<div className="flex items-center justify-center sm:justify-between">
|
<div className="flex items-center justify-center sm:justify-between">
|
||||||
@@ -103,7 +68,7 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مقدار عددی */}
|
{/* مقدار عددی به همراه واحد روز */}
|
||||||
<div
|
<div
|
||||||
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
||||||
>
|
>
|
||||||
@@ -115,8 +80,7 @@ export function StatsBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ export function useAddMaterial() {
|
|||||||
const invalidate = useInvalidateStore();
|
const invalidate = useInvalidateStore();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (m: Material) => api.materials.create(m),
|
mutationFn: (m: Material) => api.materials.create(m),
|
||||||
onMutate: (m) => addMaterial(m),
|
onSuccess: (created) => addMaterial(created),
|
||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-29
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { normalize } from "./normalize";
|
import { normalize } from "./normalize";
|
||||||
import { buildTopics } from "./topics";
|
import { buildTopics } from "./topics";
|
||||||
|
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -33,7 +34,7 @@ import type {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
||||||
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
|
|||||||
expiry_date: "تاریخ انقضا",
|
expiry_date: "تاریخ انقضا",
|
||||||
accepted_date: "تاریخ پذیرش",
|
accepted_date: "تاریخ پذیرش",
|
||||||
arrived_date: "تاریخ رسید",
|
arrived_date: "تاریخ رسید",
|
||||||
|
due_date: "تاریخ سررسید",
|
||||||
|
schedule_steps: "مراحل پرداخت",
|
||||||
|
milestones: "مراحل پرداخت",
|
||||||
|
amount: "مبلغ",
|
||||||
|
step_no: "شماره مرحله",
|
||||||
};
|
};
|
||||||
|
|
||||||
const faField = (field: string): string => {
|
export const faField = (field: string): string => {
|
||||||
const last = field.split(".").filter(Boolean).pop() || field;
|
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
|
||||||
|
const last = parts.pop() || field;
|
||||||
return FIELD_FA[last] || FIELD_FA[field] || field;
|
return FIELD_FA[last] || FIELD_FA[field] || field;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ترجمهی خطای HTTP بر اساس کد وضعیت
|
// ترجمهی خطای HTTP بر اساس کد وضعیت — پیامهای معنادار بکاند حفظ میشوند
|
||||||
const faHttpError = (status: number, fallback: string): string => {
|
export const faHttpError = (status: number, fallback: string): string => {
|
||||||
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
||||||
if (status === 404) return "مورد درخواستی یافت نشد";
|
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
|
||||||
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||||
if (status === 400) return "دادهی ارسالشده نامعتبر است";
|
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "دادهی ارسالشده نامعتبر است";
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
field?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, code?: string, field?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.field = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
headers: authHeaders(init),
|
headers: authHeaders(init),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
|
let errCode: string | undefined;
|
||||||
|
let errField: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body?.error?.message) {
|
if (body?.error) {
|
||||||
msg = body.error.message;
|
errCode = body.error.code;
|
||||||
|
errField = body.error.field;
|
||||||
|
const rawMsg = body.error.message || "";
|
||||||
|
const fName = errField ? faField(errField) : "";
|
||||||
|
if (fName && rawMsg) {
|
||||||
|
msg = `دادهی «${fName}» نامعتبر است: ${rawMsg}`;
|
||||||
|
} else if (rawMsg) {
|
||||||
|
msg = rawMsg;
|
||||||
|
}
|
||||||
} else if (Array.isArray(body?.detail)) {
|
} else if (Array.isArray(body?.detail)) {
|
||||||
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
||||||
const first = body.detail[0];
|
const first = body.detail[0];
|
||||||
const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : "";
|
const loc = first?.loc || [];
|
||||||
|
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
|
||||||
|
errField = filteredLoc.join(".");
|
||||||
|
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
|
||||||
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
||||||
msg = field ? `دادهی «${faField(field)}» نامعتبر است: ${fa}` : fa;
|
const fName = fieldName ? faField(fieldName) : "";
|
||||||
|
msg = fName ? `دادهی «${fName}» نامعتبر است: ${fa}` : fa;
|
||||||
} else if (body?.detail) {
|
} else if (body?.detail) {
|
||||||
msg = String(body.detail);
|
msg = String(body.detail);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* بدنۀ خطا JSON نیست */
|
/* بدنۀ خطا JSON نیست */
|
||||||
}
|
}
|
||||||
throw new Error(faHttpError(res.status, msg));
|
const finalMsg = faHttpError(res.status, msg);
|
||||||
|
throw new ApiError(finalMsg, res.status, errCode, errField);
|
||||||
}
|
}
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
@@ -668,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
|
|||||||
|
|
||||||
function toDateOnly(v: string | null | undefined): string | null {
|
function toDateOnly(v: string | null | undefined): string | null {
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
const str = String(v).trim();
|
const str = toAsciiDigits(v).trim();
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
if (!str) return null;
|
||||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
|
||||||
|
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سالهای ۱۳۰۰ تا ۱۵۰۰)
|
||||||
|
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
||||||
|
if (jm) {
|
||||||
|
const jy = parseInt(jm[1], 10);
|
||||||
|
const jmon = parseInt(jm[2], 10);
|
||||||
|
const jd = parseInt(jm[3], 10);
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. فرمت میلادی استاندارد YYYY-MM-DD
|
||||||
|
const gm = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (gm && parseInt(gm[1], 10) >= 1900) return str;
|
||||||
|
|
||||||
|
// ۳. رشتهی datetime کامل یا مقادیر دیگر
|
||||||
const d = new Date(str);
|
const d = new Date(str);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateTime(v: string | null | undefined): string | null {
|
function toDateTime(v: string | null | undefined): string | null {
|
||||||
const d = toDateOnly(v);
|
if (!v) return null;
|
||||||
if (!d) return null;
|
const str = toAsciiDigits(v).trim();
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
|
if (!str) return null;
|
||||||
|
|
||||||
|
// ۱. تاریخ جلالی با زمان اختیاری
|
||||||
|
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||||
|
if (jm) {
|
||||||
|
const jy = parseInt(jm[1], 10);
|
||||||
|
const jmon = parseInt(jm[2], 10);
|
||||||
|
const jd = parseInt(jm[3], 10);
|
||||||
|
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
|
||||||
|
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
|
||||||
|
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||||
|
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. اگر میلادی استاندارد ISO است
|
||||||
|
const d = new Date(str);
|
||||||
|
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnly = toDateOnly(str);
|
||||||
|
return dateOnly ? dateOnly + "T00:00:00Z" : null;
|
||||||
}
|
}
|
||||||
function todayIso(): string {
|
function todayIso(): string {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
function looksLikeUuid(v: string): boolean {
|
export function looksLikeUuid(v: string | null | undefined): boolean {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
if (!v) return false;
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -777,6 +857,13 @@ export const api = {
|
|||||||
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
||||||
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
||||||
for (let i = 0; i < lineItems.length; i++) {
|
for (let i = 0; i < lineItems.length; i++) {
|
||||||
|
let matId = realMaterialId(lineItems[i].material_id);
|
||||||
|
if (!looksLikeUuid(matId)) {
|
||||||
|
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
|
||||||
|
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
|
||||||
|
if (found) matId = found.id;
|
||||||
|
}
|
||||||
|
lineItems[i].material_id = matId;
|
||||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
||||||
for (const p of q.line_items[i].projects || []) {
|
for (const p of q.line_items[i].projects || []) {
|
||||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||||
@@ -870,7 +957,6 @@ export const api = {
|
|||||||
if (pid) targetProjByLine.set(li.id, pid);
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const allProjectIds = [...new Set([...targetProjByLine.values()])];
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
accepted_termsheets: acceptedTs.map((t) => ({
|
accepted_termsheets: acceptedTs.map((t) => ({
|
||||||
@@ -899,11 +985,7 @@ export const api = {
|
|||||||
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||||
};
|
};
|
||||||
await patchData(`/quotes/${id}/accept`, payload);
|
await patchData(`/quotes/${id}/accept`, payload);
|
||||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
return { ...current, ...patch, projects: current.projects } as Quote;
|
||||||
if (allProjectIds.length) {
|
|
||||||
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
|
|
||||||
}
|
|
||||||
return { ...current, ...patch } as Quote;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
||||||
@@ -1259,6 +1341,10 @@ async function fetchProjects(): Promise<RawProject[]> {
|
|||||||
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const projects = await fetchProjects();
|
const projects = await fetchProjects();
|
||||||
|
if (looksLikeUuid(name)) {
|
||||||
|
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
|
||||||
|
if (byId) return byId.id;
|
||||||
|
}
|
||||||
const pid = projectIdByName(projects, name);
|
const pid = projectIdByName(projects, name);
|
||||||
if (pid) return pid;
|
if (pid) return pid;
|
||||||
const raw = await postData<RawProject>("/projects", { name });
|
const raw = await postData<RawProject>("/projects", { name });
|
||||||
@@ -1390,8 +1476,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
|
|||||||
|
|
||||||
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
||||||
export function nextId(prefix: string, items: { id: string }[]): string {
|
export function nextId(prefix: string, items: { id: string }[]): string {
|
||||||
|
const regex = new RegExp(`^${prefix}(\\d+)$`);
|
||||||
const max = items
|
const max = items
|
||||||
.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10))
|
.map((x) => {
|
||||||
|
const m = String(x.id).match(regex);
|
||||||
|
return m ? parseInt(m[1], 10) : 0;
|
||||||
|
})
|
||||||
.filter((n) => !Number.isNaN(n))
|
.filter((n) => !Number.isNaN(n))
|
||||||
.reduce((a, b) => Math.max(a, b), 0);
|
.reduce((a, b) => Math.max(a, b), 0);
|
||||||
return prefix + (max + 1);
|
return prefix + (max + 1);
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ export const MONTHS = [
|
|||||||
|
|
||||||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||||
|
|
||||||
function toEnSafe(s: string): string {
|
export function toAsciiDigits(s: string | number | null | undefined): string {
|
||||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
||||||
@@ -121,18 +124,31 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
|
|||||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
|
// parse a jalali "yyyy/m/d" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string
|
||||||
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const nums = String(str)
|
const s = toAsciiDigits(str).trim();
|
||||||
|
const m = s.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||||
|
if (!m) {
|
||||||
|
const nums = s
|
||||||
.split(/[/\-.]/)
|
.split(/[/\-.]/)
|
||||||
.map((x) => parseInt(toEnSafe(x), 10));
|
.map((x) => parseInt(x, 10));
|
||||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||||
const [jy, jm, jd] = nums;
|
const [jy, jm, jd] = nums;
|
||||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
const dt = new Date(gy, gm - 1, gd);
|
const dt = new Date(gy, gm - 1, gd);
|
||||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||||
}
|
}
|
||||||
|
const jy = parseInt(m[1], 10);
|
||||||
|
const jm = parseInt(m[2], 10);
|
||||||
|
const jd = parseInt(m[3], 10);
|
||||||
|
const h = m[4] != null ? parseInt(m[4], 10) : 0;
|
||||||
|
const min = m[5] != null ? parseInt(m[5], 10) : 0;
|
||||||
|
const sec = m[6] != null ? parseInt(m[6], 10) : 0;
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
|
const dt = new Date(gy, gm - 1, gd, h, min, sec);
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function daysInMonth(jy: number, jm: number): number {
|
export function daysInMonth(jy: number, jm: number): number {
|
||||||
if (jm <= 6) return 31;
|
if (jm <= 6) return 31;
|
||||||
|
|||||||
@@ -147,17 +147,10 @@ export function lineItemTotal(li: LineItem): number | null {
|
|||||||
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
||||||
export function itemProjects(q: Quote, li: LineItem): string[] {
|
export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||||
if (q.status === "accepted" || q.status === "arrived") {
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
if (li.target_project?.name) {
|
return li.target_project?.name ? [li.target_project.name] : [];
|
||||||
return [li.target_project.name];
|
|
||||||
}
|
}
|
||||||
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تکپروژه بودن
|
// For 'offered', 'rejected', 'expired': return all candidate projects
|
||||||
if (li.projects && li.projects.length === 1) {
|
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||||
const p = li.projects[0];
|
|
||||||
return [typeof p === "string" ? p : p.name];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||||
@@ -288,13 +281,8 @@ export function filterQuotes(
|
|||||||
"پیشنهاد شده": "offered",
|
"پیشنهاد شده": "offered",
|
||||||
"پذیرفته شده": "accepted",
|
"پذیرفته شده": "accepted",
|
||||||
"رسیده": "arrived",
|
"رسیده": "arrived",
|
||||||
"باز": "offered",
|
|
||||||
"پذیرفته": "accepted",
|
|
||||||
"offered": "offered",
|
|
||||||
"accepted": "accepted",
|
|
||||||
"arrived": "arrived",
|
|
||||||
};
|
};
|
||||||
const stKey = STATUS_BY_LABEL[stVal] || stVal;
|
const stKey = STATUS_BY_LABEL[stVal] || "";
|
||||||
|
|
||||||
return store.quotes.filter((quote) => {
|
return store.quotes.filter((quote) => {
|
||||||
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
||||||
@@ -318,16 +306,11 @@ export function filterQuotes(
|
|||||||
export function isExpired(q: Quote): boolean {
|
export function isExpired(q: Quote): boolean {
|
||||||
return (
|
return (
|
||||||
q.status === "offered" &&
|
q.status === "offered" &&
|
||||||
!quoteHasArrivedOrAccepted(q) &&
|
|
||||||
!!q.expiry_date &&
|
!!q.expiry_date &&
|
||||||
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteHasArrivedOrAccepted(q: Quote): boolean {
|
|
||||||
return q.status === "accepted" || q.status === "arrived";
|
|
||||||
}
|
|
||||||
|
|
||||||
// آمادهسازی نمایش ترمشیت در حالت expanded
|
// آمادهسازی نمایش ترمشیت در حالت expanded
|
||||||
export function termSheetBlocks(
|
export function termSheetBlocks(
|
||||||
store: Store,
|
store: Store,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface AcceptModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها
|
||||||
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -50,10 +51,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
|
// تخصیص هر قلم به پروژهها (نامها)
|
||||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||||
|
// تاریخ و زمان پذیرش — پیشفرض الان
|
||||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
// وقتی quote باز شد، state اولیه را بساز
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
const offered = q.termsheets.filter((t) => t.status === "offered");
|
const offered = q.termsheets.filter((t) => t.status === "offered");
|
||||||
@@ -66,6 +70,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
});
|
});
|
||||||
setRemovedItems(rem);
|
setRemovedItems(rem);
|
||||||
setAssignments({});
|
setAssignments({});
|
||||||
|
// پروژهی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژهی پیشنهاد
|
||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||||
@@ -81,6 +86,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
|
// پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -103,6 +109,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const allItems = q.line_items || [];
|
const allItems = q.line_items || [];
|
||||||
const active = activeItems(q);
|
const active = activeItems(q);
|
||||||
|
|
||||||
|
// گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها
|
||||||
const projectOptions = (() => {
|
const projectOptions = (() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) => p.name && set.add(p.name));
|
projects.forEach((p) => p.name && set.add(p.name));
|
||||||
@@ -140,8 +147,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
|
// پچ نهایی — همهی تغییرات روی همین کلون اعمال میشود (نه روی آبجکت store)
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
|
// فقط کارتهایی با حداقل یک مرحلهی پرداخت
|
||||||
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
||||||
cards.forEach((c, idx) => {
|
cards.forEach((c, idx) => {
|
||||||
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
||||||
@@ -155,10 +164,11 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!cardMeta.size) {
|
if (!cardMeta.size) {
|
||||||
toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
|
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (removedItems.has(li.id)) li.rejected = true;
|
if (removedItems.has(li.id)) li.rejected = true;
|
||||||
});
|
});
|
||||||
@@ -168,6 +178,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند
|
||||||
const fallbackDom = [...cardMeta.keys()][0];
|
const fallbackDom = [...cardMeta.keys()][0];
|
||||||
const chosenDomIds = new Set<string>();
|
const chosenDomIds = new Set<string>();
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
@@ -189,6 +200,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
const dom = assignments[li.id] || fallbackDom;
|
const dom = assignments[li.id] || fallbackDom;
|
||||||
const finalId = domToFinal[dom];
|
const finalId = domToFinal[dom];
|
||||||
@@ -198,6 +210,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
li.accepted_ts_id = finalId || null;
|
li.accepted_ts_id = finalId || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// termsheet پذیرفتهشدهای که هیچ قلمی انتخابش نکرده → برمیگردد به offered
|
||||||
const usedIds = new Set<string>();
|
const usedIds = new Set<string>();
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
||||||
@@ -206,18 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
const projUnion = new Set<string>();
|
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
||||||
|
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const name = itemProjectsSel[li.id];
|
||||||
if (name === undefined) return;
|
if (name === undefined) return;
|
||||||
li.target_project = name ? { id: "", name } : null;
|
li.target_project = name ? { id: "", name } : null;
|
||||||
if (name) projUnion.add(name);
|
|
||||||
});
|
});
|
||||||
if (projUnion.size > 0) {
|
|
||||||
next.projects = [...projUnion];
|
|
||||||
next.project = next.projects[0] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
next.accepted_date = acceptDate || now;
|
next.accepted_date = acceptDate || now;
|
||||||
@@ -233,37 +242,26 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<div className="flex flex-col max-h-[85vh] w-full">
|
<DialogHeader>
|
||||||
|
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
|
||||||
{/* هدر ثابت و همتراز */}
|
|
||||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
|
||||||
<DialogHeader className="p-0 text-right">
|
|
||||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
|
||||||
پذیرش پیشنهاد — {sup.name}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-[13px] text-muted mt-1 text-right">
|
|
||||||
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
|
<div className="text-[12px] text-muted">
|
||||||
</p>
|
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* بدنه اسکرولپذیر */}
|
{/* کارتهای termsheet */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
||||||
{/* بخش ۱: تنظیم شرایط پرداخت */}
|
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
|
||||||
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{cards.map((c, idx) => (
|
{cards.map((c, idx) => (
|
||||||
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
|
<div key={c.domId} className="modal-variant">
|
||||||
<div className="modal-variant-head mb-2 flex items-center justify-between">
|
<div className="modal-variant-head">
|
||||||
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
|
<span className="variant-num">{cardLabel(c, idx)}</span>
|
||||||
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
|
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
{c.milestones.map((m, i) => (
|
{c.milestones.map((m, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
@@ -277,35 +275,31 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
||||||
+ مرحله پرداخت جدید
|
+ مرحله پرداخت
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
|
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
|
||||||
+ افزودن شرایط پرداخت جدید
|
+ نوشتن conditions جدید
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* بخش ۲: تخصیص اقلام */}
|
{/* تخصیص قلمها */}
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
<div className="border-t border-line pt-3">
|
||||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
|
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به conditions:</div>
|
||||||
<div className="mb-4 text-[12px] text-muted">
|
<div className="mb-2 text-[11.5px] text-muted">
|
||||||
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div>
|
||||||
{allItems.map((li) => {
|
{allItems.map((li) => {
|
||||||
const removed = removedItems.has(li.id);
|
const removed = removedItems.has(li.id);
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
||||||
key={li.id}
|
|
||||||
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
|
className="accent-accent"
|
||||||
checked={!removed}
|
checked={!removed}
|
||||||
title="تیک = در معامله میماند"
|
title="تیک = در معامله میماند"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -315,20 +309,16 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setRemovedItems(next);
|
setRemovedItems(next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-[120px]">
|
<span className="flex-1 text-[13px]">
|
||||||
<div className="text-[13.5px] font-medium text-slate-800">
|
|
||||||
{formatMaterialTitle(m)}
|
{formatMaterialTitle(m)}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-[12px] text-muted mt-0.5">
|
<span className="text-[12px] text-muted">
|
||||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
|
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
||||||
value={assignments[li.id] || cards[0]?.domId || ""}
|
value={assignments[li.id] || cards[0]?.domId || ""}
|
||||||
onChange={(e) => assign(li.id, e.target.value)}
|
onChange={(e) => assign(li.id, e.target.value)}
|
||||||
disabled={removed}
|
|
||||||
>
|
>
|
||||||
{cards.map((c, idx) => (
|
{cards.map((c, idx) => (
|
||||||
<option key={c.domId} value={c.domId}>
|
<option key={c.domId} value={c.domId}>
|
||||||
@@ -340,37 +330,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{active.length === 0 && (
|
{active.length === 0 && (
|
||||||
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
|
<div className="mb-1 text-[12px] text-red">همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.</div>
|
||||||
<span>⚠️</span> همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
|
||||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
|
||||||
<div className="mb-4 text-[12px] text-muted">
|
|
||||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
|
{/* تخصیص قلمها به پروژه */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-1 text-[13px] font-bold">پروژهی هر قلم:</div>
|
||||||
|
<div className="mb-2 text-[11.5px] text-muted">
|
||||||
|
برای هر متریال فقط یک پروژه انتخاب میشود — مثل شرایط پرداخت.
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{allItems
|
{allItems
|
||||||
.filter((li) => !removedItems.has(li.id))
|
.filter((li) => !removedItems.has(li.id))
|
||||||
.map((li) => {
|
.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-1.5 text-[12.5px]">
|
||||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
<span className="font-semibold">
|
||||||
{formatMaterialTitle(m)}
|
{formatMaterialTitle(m)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
<span className="ms-2 text-[11px] text-muted">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{projectOptions.length ? (
|
{projectOptions.length ? (
|
||||||
<select
|
<select
|
||||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
||||||
value={itemProjectsSel[li.id] ?? ""}
|
value={itemProjectsSel[li.id] ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||||
@@ -388,18 +376,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
<div className="text-[12px] text-muted">پروژهای ثبت نشده است.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
{/* فاکتورهای اصلی */}
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
|
<div className="border-t border-line pt-3">
|
||||||
<div>
|
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||||
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
|
|
||||||
<FileList
|
<FileList
|
||||||
files={invoices}
|
files={invoices}
|
||||||
onChange={setInvoices}
|
onChange={setInvoices}
|
||||||
@@ -414,10 +401,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-line w-full"></div>
|
{/* پیشفاکتورها */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
<div>
|
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||||
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیشفاکتورها</h3>
|
|
||||||
<FileList
|
<FileList
|
||||||
files={proformas}
|
files={proformas}
|
||||||
onChange={setProformas}
|
onChange={setProformas}
|
||||||
@@ -433,29 +419,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-line w-full"></div>
|
{/* تاریخ و زمان پذیرش */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
<div className="max-w-xs">
|
<div className="field max-w-xs">
|
||||||
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
|
<label>تاریخ و زمان پذیرش</label>
|
||||||
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر ثابت */}
|
<DialogFooter>
|
||||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
<Button variant="slate" onClick={onClose}>
|
||||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
|
||||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
<Button onClick={confirm}>ثبت معامله بستهشده</Button>
|
||||||
ثبت معامله بستهشده
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { filterQuotes } from "../../lib/quoteUtils";
|
import { filterQuotes } from "../../lib/quoteUtils";
|
||||||
|
import { daysBetween } from "../../lib/formatters";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { StatsBar, type StatusFilter } from "../../components/StatsBar";
|
import { StatsBar } from "../../components/StatsBar";
|
||||||
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||||
import { LedgerTable } from "./LedgerTable";
|
import { LedgerTable } from "./LedgerTable";
|
||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
@@ -55,31 +56,18 @@ export function DashboardPage() {
|
|||||||
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||||
}, [store, filters, sortDir]);
|
}, [store, filters, sortDir]);
|
||||||
|
|
||||||
// شمارش تفکیکی پیشنهادها بر اساس پایپلاین خرید
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = quotes.length;
|
const total = quotes.length;
|
||||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||||
const arrived = quotes.filter((q) => q.status === "arrived").length;
|
const leadTimes = quotes
|
||||||
return { total, offered, accepted, arrived };
|
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
||||||
|
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||||
|
.filter((x): x is number => x != null);
|
||||||
|
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
|
||||||
|
return { total, offered, accepted, avgLead };
|
||||||
}, [quotes]);
|
}, [quotes]);
|
||||||
|
|
||||||
// وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
|
|
||||||
const activeStatusFilter: StatusFilter = useMemo(() => {
|
|
||||||
if (filters.status === "offered") return "offered";
|
|
||||||
if (filters.status === "accepted") return "accepted";
|
|
||||||
if (filters.status === "arrived") return "arrived";
|
|
||||||
return "all";
|
|
||||||
}, [filters.status]);
|
|
||||||
|
|
||||||
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
|
|
||||||
const handleStatusFilterChange = (status: StatusFilter) => {
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: status === "all" ? "" : status,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
@@ -131,9 +119,7 @@ export function DashboardPage() {
|
|||||||
total={stats.total}
|
total={stats.total}
|
||||||
offered={stats.offered}
|
offered={stats.offered}
|
||||||
accepted={stats.accepted}
|
accepted={stats.accepted}
|
||||||
arrived={stats.arrived}
|
avgLeadTime={stats.avgLead}
|
||||||
activeFilter={activeStatusFilter}
|
|
||||||
onFilterChange={handleStatusFilterChange}
|
|
||||||
/>
|
/>
|
||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react";
|
import { Trash2, AlertCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
interface EditItemRow {
|
interface EditItemRow {
|
||||||
key: string;
|
key: string;
|
||||||
origLi?: LineItem;
|
origLi?: LineItem;
|
||||||
|
projects?: { id: string; name: string }[];
|
||||||
cat: string;
|
cat: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
specs: string[];
|
specs: string[];
|
||||||
@@ -52,7 +53,7 @@ interface EditItemRow {
|
|||||||
|
|
||||||
interface EditTsCard {
|
interface EditTsCard {
|
||||||
key: string;
|
key: string;
|
||||||
domId: string;
|
domId: string; // "" یا "new-ts-..." برای کارت جدید
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ export interface EditModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// مودال ویرایش کامل پیشنهاد — بهینهشده برای صفحات موبایل
|
||||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -82,7 +84,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
|
|
||||||
const [supplierName, setSupplierName] = useState("");
|
const [supplierName, setSupplierName] = useState("");
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
@@ -112,6 +113,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// گزینههای مشخصه
|
||||||
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -132,6 +134,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}, [materials, itemRows, globalSpecs]);
|
}, [materials, itemRows, globalSpecs]);
|
||||||
|
|
||||||
|
// گزینههای پروژه
|
||||||
const projectOptions = useMemo(() => {
|
const projectOptions = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) => p.name && set.add(p.name));
|
projects.forEach((p) => p.name && set.add(p.name));
|
||||||
@@ -146,7 +149,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
if (!q) return;
|
if (!q) return;
|
||||||
const sup = supplierById(suppliers, q.supplier_id);
|
const sup = supplierById(suppliers, q.supplier_id);
|
||||||
setSupplierName(sup.name);
|
setSupplierName(sup.name);
|
||||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
||||||
|
const candidateSet = new Set<string>();
|
||||||
|
(q.projects || []).forEach((p) => p && candidateSet.add(p));
|
||||||
|
if (q.project) candidateSet.add(q.project);
|
||||||
|
q.line_items.forEach((li) => {
|
||||||
|
(li.projects || []).forEach((p) => {
|
||||||
|
const n = typeof p === "string" ? p : p.name;
|
||||||
|
if (n) candidateSet.add(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setSelectedProjects([...candidateSet]);
|
||||||
setNotes(q.notes || "");
|
setNotes(q.notes || "");
|
||||||
setItemRows(
|
setItemRows(
|
||||||
q.line_items.map((li) => {
|
q.line_items.map((li) => {
|
||||||
@@ -154,6 +167,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
|
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||||||
cat: m.category || "",
|
cat: m.category || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
@@ -183,6 +197,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q, suppliers, materials]);
|
}, [q, suppliers, materials]);
|
||||||
|
|
||||||
|
// بارگذاری فاکتورها
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -218,7 +233,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
unit: "",
|
unit: "",
|
||||||
price: null,
|
price: null,
|
||||||
total: null,
|
total: null,
|
||||||
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
tsDomIds: [],
|
||||||
acceptedTsId: null,
|
acceptedTsId: null,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
restore: true,
|
restore: true,
|
||||||
@@ -249,6 +264,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
|
// تأمینکننده
|
||||||
if (supplierName.trim()) {
|
if (supplierName.trim()) {
|
||||||
const norm = normalize(supplierName);
|
const norm = normalize(supplierName);
|
||||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||||
@@ -266,6 +282,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
next.notes = notes.trim();
|
next.notes = notes.trim();
|
||||||
next.invoices = invoices;
|
next.invoices = invoices;
|
||||||
|
|
||||||
|
// termsheetها
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
tsCards.forEach((card, i) => {
|
tsCards.forEach((card, i) => {
|
||||||
@@ -282,6 +299,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
});
|
});
|
||||||
next.termsheets = newTermsheets;
|
next.termsheets = newTermsheets;
|
||||||
|
|
||||||
|
// اقلام
|
||||||
const newItems: LineItem[] = [];
|
const newItems: LineItem[] = [];
|
||||||
const materialsCopy = [...materials];
|
const materialsCopy = [...materials];
|
||||||
for (const row of itemRows) {
|
for (const row of itemRows) {
|
||||||
@@ -343,8 +361,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
acceptedTsId = null;
|
acceptedTsId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candProjs =
|
||||||
|
row.projects && row.projects.length
|
||||||
|
? row.projects
|
||||||
|
: row.origLi?.projects && row.origLi.projects.length
|
||||||
|
? row.origLi.projects
|
||||||
|
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
||||||
|
|
||||||
newItems.push({
|
newItems.push({
|
||||||
id: "li-" + (newItems.length + 1),
|
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
|
||||||
material_id: materialId,
|
material_id: materialId,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit,
|
unit,
|
||||||
@@ -354,7 +379,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
accepted_ts_id: acceptedTsId,
|
accepted_ts_id: acceptedTsId,
|
||||||
rejected,
|
rejected,
|
||||||
arrived_date: row.origLi?.arrived_date ?? null,
|
arrived_date: row.origLi?.arrived_date ?? null,
|
||||||
projects: row.origLi?.projects ?? [],
|
projects: candProjs,
|
||||||
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
||||||
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
||||||
});
|
});
|
||||||
@@ -370,6 +395,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// تاریخها و وضعیت
|
||||||
next.quote_date = quoteDate || q.quote_date;
|
next.quote_date = quoteDate || q.quote_date;
|
||||||
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
||||||
next.accepted_date = acceptedDate;
|
next.accepted_date = acceptedDate;
|
||||||
@@ -409,103 +435,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
|
||||||
{/* هدر سفید و تمیز */}
|
<DialogHeader>
|
||||||
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
|
||||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
|
||||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
|
||||||
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{hasReceipts && (
|
{hasReceipts && (
|
||||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
|
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber">
|
||||||
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تببار سفید و استاندارد */}
|
{/* اطلاعات بالایی — تکستونه در موبایل، ۲ ستونه در دسکتاپ */}
|
||||||
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("items")}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
|
||||||
activeTab === "items"
|
|
||||||
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
|
||||||
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Package className="h-3.5 w-3.5" />
|
|
||||||
<span>اقلام و سفارش</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
|
||||||
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{faNum(itemRows.length)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("terms")}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
|
||||||
activeTab === "terms"
|
|
||||||
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
|
||||||
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CreditCard className="h-3.5 w-3.5" />
|
|
||||||
<span>شرایط پرداخت</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
|
||||||
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{faNum(tsCards.length)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("dates")}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
|
||||||
activeTab === "dates"
|
|
||||||
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
|
||||||
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
<span>وضعیت و اسناد</span>
|
|
||||||
{invoices.length > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
|
||||||
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{faNum(invoices.length)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
|
||||||
{/* تب ۱: اقلام و مشخصات */}
|
|
||||||
{activeTab === "items" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* کارت تأمینکننده و پروژه */}
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<label className="text-xs font-semibold text-gray-600">تأمینکننده</label>
|
<label className="text-xs font-semibold text-muted">تأمینکننده</label>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
<Combobox
|
<Combobox
|
||||||
value={supplierName}
|
value={supplierName}
|
||||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||||
@@ -516,9 +462,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
onInputChange={(v) => setSupplierName(v)}
|
onInputChange={(v) => setSupplierName(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<label className="text-xs font-semibold text-gray-600">پروژه</label>
|
<label className="text-xs font-semibold text-muted">پروژه</label>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={selectedProjects}
|
values={selectedProjects}
|
||||||
onChange={setSelectedProjects}
|
onChange={setSelectedProjects}
|
||||||
@@ -528,82 +476,67 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||||
<label className="text-xs font-semibold text-gray-600">یادداشت</label>
|
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30"
|
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
value={notes}
|
value={notes}
|
||||||
placeholder="یادداشتهای داخلی این معامله..."
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لیست اقلام */}
|
{/* اقلام */}
|
||||||
<div className="space-y-3">
|
<div className="border-t border-line pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
<div className="flex flex-col gap-3">
|
||||||
</div>
|
{itemRows.map((row) => (
|
||||||
|
|
||||||
{itemRows.map((row, idx) => (
|
|
||||||
<div
|
<div
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className={cn(
|
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`}
|
||||||
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
|
|
||||||
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
{/* متریال — تمامعرض در موبایل */}
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
|
||||||
{faNum(idx + 1)}
|
<label className="text-[11px] font-semibold text-muted">متریال</label>
|
||||||
</span>
|
|
||||||
<span className="text-xs font-bold text-gray-900 truncate">
|
|
||||||
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!hasReceipts && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[11px]">حذف</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
|
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
placeholder="نام متریال"
|
placeholder="متریال"
|
||||||
value={row.cat}
|
value={row.cat}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* برند */}
|
||||||
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">برند</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
value={row.brand}
|
value={row.brand}
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* واحد */}
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">واحد</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
placeholder="واحد"
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
{/* مشخصه — تمامعرض در موبایل */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
<label className="text-[11px] font-semibold text-muted">مشخصه</label>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
values={row.specs}
|
values={row.specs}
|
||||||
@@ -612,23 +545,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
placeholder="مشخصه"
|
placeholder="مشخصه"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
|
||||||
<input
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
|
||||||
placeholder="واحد سنجش"
|
|
||||||
value={row.unit}
|
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جعبه ۳ عددی مقادیر */}
|
{/* تعداد */}
|
||||||
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<label className="text-[11px] font-semibold text-muted">تعداد</label>
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.qty}
|
value={row.qty}
|
||||||
@@ -642,8 +563,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* قیمت واحد */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.price}
|
value={row.price}
|
||||||
@@ -653,13 +575,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="واحد"
|
placeholder="قیمت واحد"
|
||||||
maxDecimals={2}
|
maxDecimals={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* قیمت کل */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
@@ -670,45 +593,48 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="جمع"
|
placeholder="قیمت کل"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف */}
|
||||||
|
<div className="col-span-1 flex items-end justify-end pb-0.5">
|
||||||
|
{!hasReceipts && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-xs text-red hover:bg-red-bg/50 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span>حذف قلم</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* شرایط پرداخت برای این قلم */}
|
{/* تخصیص termsheet */}
|
||||||
<div className="pt-2 border-t border-gray-100">
|
<div className="mt-2.5 border-t border-line/60 pt-2">
|
||||||
{row.rejected ? (
|
{row.rejected ? (
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
<label className="ts-cb-label e-restore">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-blue-600 h-4 w-4 rounded"
|
|
||||||
checked={row.restore}
|
checked={row.restore}
|
||||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
بازگرداندن این قلم به معامله
|
بازگرداندن به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1.5">
|
<>
|
||||||
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tsCards.map((card, cIdx) => {
|
{tsCards.map((card, idx) => (
|
||||||
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={card.key}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
|
||||||
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
|
||||||
hasReceipts && "cursor-not-allowed opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
className="me-1 accent-accent disabled:cursor-not-allowed"
|
||||||
checked={isSelected}
|
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = new Set(row.tsDomIds);
|
const next = new Set(row.tsDomIds);
|
||||||
const domId = card.domId || card.key;
|
const domId = card.domId || card.key;
|
||||||
@@ -717,170 +643,73 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
patchItem(row.key, { tsDomIds: [...next] });
|
patchItem(row.key, { tsDomIds: [...next] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{tsLabel(card, cIdx)}</span>
|
{tsLabel(card, idx)}
|
||||||
</label>
|
</label>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!hasReceipts && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="line"
|
|
||||||
size="sm"
|
|
||||||
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
|
|
||||||
onClick={addItemRow}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
<span>افزودن قلم جدید</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* تب ۲: شرایط پرداخت */}
|
|
||||||
{activeTab === "terms" && (
|
|
||||||
<div className="space-y-3.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
|
||||||
type="button"
|
+ افزودن قلم
|
||||||
variant="line"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1 text-xs text-gray-700"
|
|
||||||
onClick={addTsCard}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
<span>افزودن گزینه جدید</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
{/* termsheetها */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, idx) => (
|
||||||
<div
|
<div key={card.key} className="variant-card">
|
||||||
key={card.key}
|
<div className="variant-head">
|
||||||
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||||
>
|
{!hasReceipts && (
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||||
<div className="flex items-center gap-1.5">
|
×
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
|
||||||
{faNum(idx + 1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
|
|
||||||
<span className="text-[11px] text-gray-500 font-medium">
|
|
||||||
({faNum(card.milestones.length)} مرحله)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!hasReceipts && tsCards.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
|
||||||
title="حذف گزینه"
|
|
||||||
onClick={() => removeTsCard(card.key)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[11px]">حذف</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-1 flex flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
|
||||||
{card.milestones.map((ms, i) => (
|
{card.milestones.map((ms, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
milestone={ms}
|
milestone={ms}
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
onChange={(nextMs) =>
|
onChange={(nextMs) =>
|
||||||
updateTs(card.key, {
|
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||||||
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onRemove={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.filter((_, j) => j !== i),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="line"
|
variant="line"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto text-xs text-gray-700"
|
className="mt-1"
|
||||||
onClick={() =>
|
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 me-1" />
|
+ مرحله پرداخت
|
||||||
افزودن مرحله پرداخت
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{!hasReceipts && (
|
||||||
|
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
|
||||||
|
+ افزودن termsheet
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تب ۳: وضعیت و اسناد */}
|
|
||||||
{activeTab === "dates" && (
|
|
||||||
<div className="space-y-3.5">
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
|
||||||
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
|
|
||||||
<select
|
|
||||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
|
||||||
value={status}
|
|
||||||
disabled={hasReceipts}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
{statusOptionsFor(q.status).map((st) => (
|
|
||||||
<option key={st} value={st}>
|
|
||||||
{STATUS_LABEL[st]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="text-[11px] text-gray-500">
|
|
||||||
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
|
{/* فاکتورها */}
|
||||||
<div className="text-xs font-bold text-gray-900">تاریخهای چرخه خرید</div>
|
<div className="border-t border-line pt-3">
|
||||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
|
|
||||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
|
|
||||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
|
|
||||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
|
|
||||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
|
||||||
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوستها</div>
|
|
||||||
<FileList
|
<FileList
|
||||||
files={invoices}
|
files={invoices}
|
||||||
onChange={setInvoices}
|
onChange={setInvoices}
|
||||||
@@ -894,32 +723,70 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* تاریخها و وضعیت */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">تاریخ پیشنهاد</label>
|
||||||
|
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
|
||||||
|
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
|
||||||
|
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
|
||||||
|
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||||
|
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
value={status}
|
||||||
|
disabled={hasReceipts}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusOptionsFor(q.status).map((st) => (
|
||||||
|
<option key={st} value={st}>
|
||||||
|
{STATUS_LABEL[st]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{hasReceipts && (
|
||||||
|
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||||||
|
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* فوتر سفید، محکم و چسبان */}
|
{/* فوتر و دکمههای اقدام */}
|
||||||
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{q.status === "offered" ? (
|
{q.status === "offered" ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
|
className="w-full gap-1.5 sm:w-auto"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">حذف</span>
|
<span>حذف پیشنهاد</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div />
|
||||||
)}
|
)}
|
||||||
|
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
|
||||||
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
|
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
|
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
|
||||||
ذخیره تغییرات
|
ذخیره تغییرات
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,20 +794,19 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* مودال تایید حذف سفید */}
|
{/* مودال تأیید حذف */}
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
|
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-2 text-xs sm:text-sm text-gray-600">
|
<div className="py-2 text-sm text-muted">
|
||||||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="slate"
|
variant="slate"
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
@@ -949,7 +815,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleDelete()}
|
onClick={() => void handleDelete()}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
|
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -9,33 +10,8 @@ export interface Filters {
|
|||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// گزینههای استاندارد و یکپارچه وضعیت هماهنگ با کارتهای بالای صفحه
|
// لیست وضعیتها از dictionary واحد (constants) — نه آرایهی تکراری
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
|
||||||
{ id: "offered", label: "پیشنهاد باز" },
|
|
||||||
{ id: "accepted", label: "پذیرفتهشده" },
|
|
||||||
{ id: "arrived", label: "تحویلشده" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, string> = {
|
|
||||||
offered: "پیشنهاد باز",
|
|
||||||
accepted: "پذیرفتهشده",
|
|
||||||
arrived: "تحویلشده",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_TO_KEY: Record<string, string> = {
|
|
||||||
"پیشنهاد باز": "offered",
|
|
||||||
"پذیرفتهشده": "accepted",
|
|
||||||
"تحویلشده": "arrived",
|
|
||||||
"پیشنهاد شده": "offered",
|
|
||||||
"پذیرفته شده": "accepted",
|
|
||||||
"رسیده": "arrived",
|
|
||||||
"باز": "offered",
|
|
||||||
"پذیرفته": "accepted",
|
|
||||||
"تحویل": "arrived",
|
|
||||||
offered: "offered",
|
|
||||||
accepted: "accepted",
|
|
||||||
arrived: "arrived",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FilterToolbarProps {
|
export interface FilterToolbarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
@@ -64,14 +40,6 @@ export function FilterToolbar({
|
|||||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
onChange({ ...filters, category: "", supplier: "", status: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
|
||||||
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
|
|
||||||
|
|
||||||
const handleStatusChange = (val: string) => {
|
|
||||||
const key = STATUS_TO_KEY[val] || val;
|
|
||||||
set({ status: key });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||||
@@ -180,12 +148,12 @@ export function FilterToolbar({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={statusDisplayValue}
|
value={filters.status}
|
||||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||||
items={STATUS_OPTIONS}
|
items={STATUS_ITEMS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه وضعیتها"
|
placeholder="همه وضعیتها"
|
||||||
onInputChange={handleStatusChange}
|
onInputChange={(v) => set({ status: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,12 +197,12 @@ export function FilterToolbar({
|
|||||||
<div className="field min-w-[150px] flex-1">
|
<div className="field min-w-[150px] flex-1">
|
||||||
<label>وضعیت</label>
|
<label>وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={statusDisplayValue}
|
value={filters.status}
|
||||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||||
items={STATUS_OPTIONS}
|
items={STATUS_ITEMS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه"
|
placeholder="همه"
|
||||||
onInputChange={handleStatusChange}
|
onInputChange={(v) => set({ status: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field min-w-[200px] flex-1">
|
<div className="field min-w-[200px] flex-1">
|
||||||
|
|||||||
@@ -243,7 +243,32 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
{/* ۳. مجموع مبلغ */}
|
<InfoCell label="پروژه">
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
||||||
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<span className="truncate">
|
||||||
|
{(() => {
|
||||||
|
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
|
// فقط پروژهی هدف پذیرفتهشده — همان منطق itemProjects در وضعیت accepted
|
||||||
|
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
|
||||||
|
return targetProj?.name ?? projectCell(q);
|
||||||
|
}
|
||||||
|
// وضعیت offered: همهی پروژههای کاندیدا از line_items (همان منطق itemProjects)
|
||||||
|
const names = new Set<string>();
|
||||||
|
activeItems.forEach((li) =>
|
||||||
|
(li.projects || []).forEach((p) => {
|
||||||
|
const name = typeof p === "string" ? p : p.name;
|
||||||
|
if (name) names.add(name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (names.size > 0) return [...names].join("، ");
|
||||||
|
return projectCell(q);
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { nextId } from "../../lib/api";
|
|||||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { topicByName } from "../../lib/topics";
|
||||||
import { daysBetween } from "../../lib/formatters";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
@@ -16,6 +15,7 @@ import { materialLabel } from "../../lib/quoteUtils";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
|
// (سلسلهمراتب دامنه: دسته → تأمینکننده؛ در v2.6 دسته = تاپیک)
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const { category: catParam = "" } = useParams();
|
const { category: catParam = "" } = useParams();
|
||||||
@@ -36,29 +36,12 @@ export function CategoryPage() {
|
|||||||
contact: "",
|
contact: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// همهی تأمینکنندهها برای انتخاب سریع
|
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
||||||
const supplierOptions = useMemo(
|
const supplierOptions = useMemo(
|
||||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||||
[suppliers],
|
[suppliers],
|
||||||
);
|
);
|
||||||
|
|
||||||
// محاسبه میانگین زمان تحویل اختصاصی هر تأمینکننده بر اساس سفارشهای تحویلشده
|
|
||||||
const supLeadTimes = useMemo(() => {
|
|
||||||
const map = new Map<string, number | null>();
|
|
||||||
suppliers.forEach((s) => {
|
|
||||||
const supQuotes = quotes.filter(
|
|
||||||
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
|
||||||
);
|
|
||||||
const times = supQuotes
|
|
||||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
|
||||||
.filter((x): x is number => x != null);
|
|
||||||
|
|
||||||
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
|
||||||
map.set(s.id, avg);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [quotes, suppliers]);
|
|
||||||
|
|
||||||
if (!cat) return <Navigate to="/suppliers" replace />;
|
if (!cat) return <Navigate to="/suppliers" replace />;
|
||||||
|
|
||||||
const sups = suppliersOfCategory(cat, cat, suppliers);
|
const sups = suppliersOfCategory(cat, cat, suppliers);
|
||||||
@@ -86,6 +69,7 @@ export function CategoryPage() {
|
|||||||
toast.error("نام تأمینکننده را وارد کنید.");
|
toast.error("نام تأمینکننده را وارد کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// تأمینکننده دقیقاً زیر همین دسته ثبت میشود (۱ دسته به ازای هر تأمینکننده)
|
||||||
const s: Supplier = {
|
const s: Supplier = {
|
||||||
id: nextId("s", suppliers),
|
id: nextId("s", suppliers),
|
||||||
topic: cat,
|
topic: cat,
|
||||||
@@ -117,15 +101,7 @@ export function CategoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{sups.map((s) => (
|
{sups.map((s) => (
|
||||||
<SupplierCard
|
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||||
key={s.id}
|
|
||||||
s={s}
|
|
||||||
topic={cat}
|
|
||||||
deletable
|
|
||||||
onDelete={removeSup}
|
|
||||||
materials={supMats.get(s.id) || []}
|
|
||||||
avgLeadTime={supLeadTimes.get(s.id)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
import { MapPin, Phone, User, Plus } from "lucide-react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Material, Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
|
||||||
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
materials
|
materials
|
||||||
@@ -21,45 +19,33 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت تأمینکننده همراه با برچسب میانگین زمان تحویل
|
// کارت تأمینکننده
|
||||||
export function SupplierCard({
|
export function SupplierCard({
|
||||||
s,
|
s,
|
||||||
topic,
|
topic,
|
||||||
deletable,
|
deletable,
|
||||||
onDelete,
|
onDelete,
|
||||||
materials = [],
|
materials = [],
|
||||||
avgLeadTime,
|
|
||||||
}: {
|
}: {
|
||||||
s: Supplier;
|
s: Supplier;
|
||||||
topic: string;
|
topic: string;
|
||||||
deletable?: boolean;
|
deletable?: boolean;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
||||||
materials?: string[];
|
materials?: string[];
|
||||||
avgLeadTime?: number | null;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head flex items-start justify-between gap-2">
|
<div className="sc-head">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
{avgLeadTime != null && (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
|
|
||||||
title="میانگین زمان تحویل سفارشها"
|
|
||||||
>
|
|
||||||
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
|
|
||||||
<span>{fmtNum(avgLeadTime)} روز</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{deletable && onDelete && (
|
{deletable && onDelete && (
|
||||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
<div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
|
||||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={supplierName}
|
value={supplierName}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
<span>قیمت کل</span>
|
<span>قیمت کل</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div data-field="line_items" className="flex flex-col gap-2">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||||
<Combobox
|
<Combobox
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div data-field="termsheets" className="flex flex-col gap-3">
|
||||||
{cards.map((card, idx) => (
|
{cards.map((card, idx) => (
|
||||||
<TermsheetCard
|
<TermsheetCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-field="project">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
<h3 className="mb-1 text-[15px] font-bold">
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
||||||
پروژهها
|
پروژهها
|
||||||
|
|||||||
@@ -57,20 +57,20 @@ export function Step5Review({
|
|||||||
|
|
||||||
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
||||||
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
||||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
|
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
|
||||||
<label>تاریخ پیشنهاد</label>
|
<label>تاریخ پیشنهاد</label>
|
||||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||||
<div className="hint">پیشفرض امروز</div>
|
<div className="hint">پیشفرض امروز</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
||||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
|
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
|
||||||
<label>تاریخ انقضای پیشنهاد</label>
|
<label>تاریخ انقضای پیشنهاد</label>
|
||||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field col-span-12">
|
<div className="field col-span-12" data-field="notes">
|
||||||
<label>یادداشت (اختیاری)</label>
|
<label>یادداشت (اختیاری)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -81,7 +81,7 @@ export function Step5Review({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field col-span-12">
|
<div className="field col-span-12" data-field="proforma">
|
||||||
<label>پیشفاکتور (اختیاری)</label>
|
<label>پیشفاکتور (اختیاری)</label>
|
||||||
{proforma ? (
|
{proforma ? (
|
||||||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { LineItem, Quote } from "../../lib/types";
|
import type { LineItem, Quote } from "../../lib/types";
|
||||||
import { api, nextId } from "../../lib/api";
|
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
useAddProject,
|
useAddProject,
|
||||||
@@ -125,16 +125,8 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {
|
||||||
void addMaterial.mutateAsync({
|
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||||
id: nextId("m", materials),
|
|
||||||
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
|
|
||||||
topic: rec.topic,
|
|
||||||
category: rec.category,
|
|
||||||
brand: rec.brand,
|
|
||||||
specs: rec.specs,
|
|
||||||
base_unit: "",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||||
@@ -193,19 +185,72 @@ export function WizardPage() {
|
|||||||
|
|
||||||
// ---------- ذخیره ----------
|
// ---------- ذخیره ----------
|
||||||
const doSave = async () => {
|
const doSave = async () => {
|
||||||
const sectionError = (step: number, title: string, err: unknown) => {
|
const resolveErrorTarget = (err: unknown): { step: number; field?: string } => {
|
||||||
|
let field = "";
|
||||||
|
let msg = "";
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
field = err.field || "";
|
||||||
|
msg = err.message || "";
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
msg = err.message || "";
|
||||||
|
if ("field" in err && typeof (err as any).field === "string") {
|
||||||
|
field = (err as any).field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
|
// ۱. تأمینکننده
|
||||||
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
|
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
||||||
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
|
return { step: 2, field: field || "line_items" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
||||||
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
|
return { step: 3, field: field || "termsheets" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۴. پروژه
|
||||||
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
|
// ۵. تاریخها، یادداشت، پیشفاکتور
|
||||||
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
|
if (/notes|یادداشت/i.test(combined)) return { step: 5, field: "notes" };
|
||||||
|
|
||||||
|
return { step: 5, field };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionError = (stepOrTarget: number | { step: number; field?: string }, title: string, err: unknown) => {
|
||||||
|
const target = typeof stepOrTarget === "number" ? { step: stepOrTarget } : stepOrTarget;
|
||||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||||
toast.error(`${title}: ${msg}`, {
|
toast.error(`${title}: ${msg}`, {
|
||||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
duration: 8000,
|
||||||
|
action: {
|
||||||
|
label: "رفع خطا",
|
||||||
|
onClick: () => {
|
||||||
|
goToStep(target.step);
|
||||||
|
setTimeout(() => {
|
||||||
|
const targetAttr = target.field ? `[data-field="${target.field}"]` : "";
|
||||||
|
const el = (targetAttr ? document.querySelector(targetAttr) : null) ||
|
||||||
|
document.querySelector(`[data-step="${target.step}"]`);
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
const input = el.querySelector("input, textarea, select") as HTMLElement | null;
|
||||||
|
if (input) input.focus();
|
||||||
|
else el.focus?.();
|
||||||
|
el.classList.add("ring-2", "ring-red-500", "rounded-xl");
|
||||||
|
setTimeout(() => el.classList.remove("ring-2", "ring-red-500", "rounded-xl"), 3500);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const guessStep = (msg: string): number => {
|
|
||||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
|
||||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
|
||||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
|
||||||
if (/پروژه|project/i.test(msg)) return 4;
|
|
||||||
return 5;
|
|
||||||
};
|
|
||||||
|
|
||||||
const name = supplierName.trim();
|
const name = supplierName.trim();
|
||||||
let supplierId = "";
|
let supplierId = "";
|
||||||
@@ -244,10 +289,11 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
material = materials.find(
|
material = materials.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
|
looksLikeUuid(m.id) &&
|
||||||
normalize(m.category) === normalize(cat) &&
|
normalize(m.category) === normalize(cat) &&
|
||||||
normalize(m.brand) === normalize(brand) &&
|
normalize(m.brand) === normalize(brand) &&
|
||||||
specsKey(m.specs) === specsKey(specs),
|
specsKey(m.specs) === specsKey(specs),
|
||||||
@@ -257,11 +303,11 @@ export function WizardPage() {
|
|||||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
||||||
if (!material && cat) {
|
if (!material && cat) {
|
||||||
material = materials.find(
|
material = materials.find(
|
||||||
(m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material && looksLikeUuid(material.id)) {
|
||||||
materialId = material.id;
|
materialId = material.id;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -272,7 +318,7 @@ export function WizardPage() {
|
|||||||
category: cat,
|
category: cat,
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit || "عدد",
|
||||||
});
|
});
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -361,8 +407,8 @@ export function WizardPage() {
|
|||||||
toast.success("پیشنهاد ذخیره شد.");
|
toast.success("پیشنهاد ذخیره شد.");
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
const target = resolveErrorTarget(e);
|
||||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
sectionError(target, "خطا در ثبت پیشنهاد", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,7 +438,7 @@ export function WizardPage() {
|
|||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -416,12 +462,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -432,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -446,7 +492,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
|
|||||||
Reference in New Issue
Block a user