5 Commits
26 changed files with 1297 additions and 1296 deletions
-1
View File
@@ -119,7 +119,6 @@ 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
-13
View File
@@ -108,7 +108,6 @@ 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.
@@ -122,11 +121,6 @@ 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]}
@@ -139,13 +133,6 @@ 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}}
+23 -110
View File
@@ -290,78 +290,12 @@ 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)
@@ -375,11 +309,6 @@ 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)
@@ -388,21 +317,10 @@ 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
@@ -428,7 +346,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)) .where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
.values( .values(
status="pending", status="pending",
decided_at=None, decided_at=None,
@@ -606,7 +524,21 @@ 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()
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written. # --- per-material project allocation (تخصیص پروژه به هر قلم) ---
for entry in payload.item_projects:
if entry.line_item_id not in li_ids:
raise NotFoundError(
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
)
await db.execute(
delete(LineItemProjects)
.where(LineItemProjects.line_item_id == entry.line_item_id)
.execution_options(synchronize_session=False)
)
for pid in entry.project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
# 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:
@@ -803,36 +735,20 @@ 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()
# collect candidate material_ids for orphan cleanup before deleting quote # clean up attachments in MinIO before cascade
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()
att_keys = [att.object_key for att in attachments] for att in attachments:
quote_prefix = f"{quote.id}/" try:
await storage.remove_object(att.object_key)
except Exception:
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
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),
@@ -841,13 +757,10 @@ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-19
View File
@@ -66,22 +66,3 @@ 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)
+14 -51
View File
@@ -1,7 +1,6 @@
services: services:
postgres: postgres:
image: postgres:18 image: postgres:18
container_name: procurement-postgres
environment: environment:
POSTGRES_DB: procurement POSTGRES_DB: procurement
@@ -9,7 +8,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
ports: ports:
- "5433:5432" - "5435:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
@@ -24,24 +23,13 @@ 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
# Containers are on several networks; Traefik must use the dedicated - --providers.docker.network=erp_production_network
# 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
@@ -51,11 +39,9 @@ services:
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --log.level=INFO - --log.level=INFO
ports: ports:
- "80:80" - "8084:80"
- "443:443" - "8445: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
@@ -64,7 +50,6 @@ 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"
@@ -73,20 +58,18 @@ services:
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: minioadmin
ports: ports:
- "9000:9000" # S3 API - "9004:9000" # S3 API
- "9001:9001" # Web console - "9005: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
@@ -98,11 +81,8 @@ 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:
@@ -125,20 +105,8 @@ 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
@@ -147,9 +115,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:8080" origin: "http://localhost:8083"
ports: ports:
- "8080:8000" - "8083:8000"
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -167,7 +135,6 @@ 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
@@ -176,21 +143,18 @@ 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:8080 PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083
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:
- "8000:8000" - "8001:8000"
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -214,9 +178,10 @@ 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
@@ -234,10 +199,8 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network: traefik_network:
name: traefik_network name: erp_production_network
driver: bridge driver: bridge
volumes: volumes:
+193 -16
View File
@@ -1,5 +1,6 @@
import { useRef } from "react"; import { useRef, useState } 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 {
@@ -14,9 +15,18 @@ export interface FileListProps {
label?: string; label?: string;
} }
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده const isImageFile = (name: string = "") => {
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
// مدیریت چند فاکتور: آپلود مستقیم، پیش‌نمایش آنلاین تصویر/PDF و دانلود
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];
@@ -39,22 +49,94 @@ 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> <div className="space-y-2">
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>} {!files.length && (
{files.map((inv, i) => ( <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
<div key={i} className="inv-row"> هنوز فاکتور یا سندی بارگذاری نشده است.
<span className="inv-name">📎 {inv.name}</span> </div>
{urlOf?.(inv) && (
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
↓
</a>
)} )}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
× {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>
</>
)}
<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}
@@ -64,10 +146,105 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
className="hidden" className="hidden"
onChange={handleChange} onChange={handleChange}
/> />
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}> <button
+ {label} type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button>
</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> </button>
</div> </div>
</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>
); );
} }
+44 -192
View File
@@ -6,7 +6,6 @@ import {
jalaliStrToIso, jalaliStrToIso,
jalaliToGregorian, jalaliToGregorian,
MONTHS, MONTHS,
toAsciiDigits,
toFa, toFa,
WEEKDAYS, WEEKDAYS,
} from "../lib/jalali"; } from "../lib/jalali";
@@ -30,7 +29,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);
@@ -79,10 +78,6 @@ 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);
@@ -97,28 +92,19 @@ 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 hh = String(t.h).padStart(2, "0"); const d = new Date(value);
const mm = String(t.m).padStart(2, "0"); if (Number.isNaN(d.getTime())) {
setText(j);
} else {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`); 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);
@@ -130,13 +116,14 @@ 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;
@@ -176,108 +163,36 @@ export function JalaliDatePicker({
}; };
const selectDay = (jy: number, jm: number, jd: number) => { const selectDay = (jy: number, jm: number, jd: number) => {
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
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(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}` `${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")) : ""}`
); );
setOpen(false); setOpen(false);
}; };
const handleHourChange = (raw: string) => { const setTimePart = (part: "h" | "m", raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, ""); const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
if (digits.length === 3) { if (Number.isNaN(n)) return;
digits = digits.slice(-1); const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
} else if (digits.length > 3) { const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
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;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); onChange(buildIso(jy, jm, jd, h, m));
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, h, m));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
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, nh, nm) ? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
: 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(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}` ? `${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"))}`, : `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
); );
setOpen(false); setOpen(false);
@@ -286,24 +201,17 @@ 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(toAsciiDigits(x), 10)); .map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 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")}`);
@@ -318,51 +226,6 @@ 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
@@ -375,7 +238,6 @@ 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
@@ -386,7 +248,6 @@ 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")}>
« «
@@ -404,8 +265,6 @@ 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">
@@ -432,40 +291,33 @@ 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-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5"> <div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input
type="text"
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
value={minuteText}
inputMode="numeric"
onFocus={(e) => e.target.select()}
onChange={(e) => handleMinuteChange(e.target.value)}
onBlur={handleMinuteBlur}
/>
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
</div>
<span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5">
<input
type="text"
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
value={hourText}
inputMode="numeric"
onFocus={(e) => e.target.select()}
onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur}
/>
<span className="text-[11.5px] font-semibold text-muted">ساعت</span> <span className="text-[11.5px] font-semibold text-muted">ساعت</span>
<input
type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
value={faNum(String(time.h).padStart(2, "0"))}
inputMode="numeric"
maxLength={2}
onChange={(e) => setTimePart("h", e.target.value)}
/>
</div>
<span className="text-muted">:</span>
<div className="flex items-center gap-1.5">
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
<input
type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
value={faNum(String(time.m).padStart(2, "0"))}
inputMode="numeric"
maxLength={2}
onChange={(e) => setTimePart("m", e.target.value)}
/>
</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}>
امروز امروز
+15 -4
View File
@@ -12,15 +12,19 @@ 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="milestone-row"> <div className="flex items-start gap-3 w-full my-2">
{/* کادر انتخاب تاریخ */}
<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 className="ms-amount-wrap"> </div>
{/* کادر ورود مبلغ و حروف زیر آن */}
<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 })}
@@ -29,8 +33,15 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
disabled={disabled} disabled={disabled}
/> />
</div> </div>
{/* دکمه حذف */}
{!disabled && ( {!disabled && (
<button type="button" className="ms-x" title="حذف" onClick={onRemove}> <button
type="button"
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
title="حذف"
onClick={onRemove}
>
× ×
</button> </button>
)} )}
+48 -12
View File
@@ -1,15 +1,27 @@
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react"; import { ClipboardList, Inbox, CheckCircle2, PackageCheck, 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;
avgLeadTime: number | null; // روز arrived: number;
activeFilter?: StatusFilter;
onFilterChange?: (filter: StatusFilter) => void;
} }
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { export function StatsBar({
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;
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: LucideIcon; icon: LucideIcon;
}[] = [ }[] = [
{ {
key: "all",
label: "کل پیشنهادها", label: "کل پیشنهادها",
shortLabel: "کل", shortLabel: "کل",
value: fmtNum(total), value: fmtNum(total),
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: ClipboardList, icon: ClipboardList,
}, },
{ {
key: "offered",
label: "پیشنهاد باز", label: "پیشنهاد باز",
shortLabel: "باز", shortLabel: "باز",
value: fmtNum(offered), value: fmtNum(offered),
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: Inbox, icon: Inbox,
}, },
{ {
key: "accepted",
label: "پذیرفته‌شده", label: "پذیرفته‌شده",
shortLabel: "پذیرفته", shortLabel: "پذیرفته",
value: fmtNum(accepted), value: fmtNum(accepted),
@@ -39,21 +54,41 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: CheckCircle2, icon: CheckCircle2,
}, },
{ {
label: "میانگین زمان تحویل", key: "arrived",
shortLabel: "میانگین تحویل", label: "تحویل‌شده به کارگاه",
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), shortLabel: "تحویل",
unit: avgLeadTime != null ? "روز" : undefined, value: fmtNum(arrived),
color: "text-green", color: "text-green",
icon: Timer, icon: PackageCheck,
}, },
]; ];
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}
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" onClick={() => handleClick(s.key)}
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
isClickable ? "cursor-pointer select-none" : ""
} ${
isActive
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
: "border-line bg-panel hover:border-accent/40"
}`}
> >
{/* عنوان کارت */} {/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between"> <div className="flex items-center justify-center sm:justify-between">
@@ -68,7 +103,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
</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}`}
> >
@@ -80,7 +115,8 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
)} )}
</div> </div>
</div> </div>
))} );
})}
</section> </section>
); );
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export function useAddMaterial() {
const invalidate = useInvalidateStore(); const invalidate = useInvalidateStore();
return useMutation({ return useMutation({
mutationFn: (m: Material) => api.materials.create(m), mutationFn: (m: Material) => api.materials.create(m),
onSuccess: (created) => addMaterial(created), onMutate: (m) => addMaterial(m),
onSettled: () => invalidate(), onSettled: () => invalidate(),
}); });
} }
+29 -119
View File
@@ -14,7 +14,6 @@
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,
@@ -34,7 +33,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 مقدار
@@ -136,42 +135,22 @@ 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: "شماره مرحله",
}; };
export const faField = (field: string): string => { const faField = (field: string): string => {
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p)); const last = field.split(".").filter(Boolean).pop() || field;
const last = parts.pop() || field;
return FIELD_FA[last] || FIELD_FA[field] || field; return FIELD_FA[last] || FIELD_FA[field] || field;
}; };
// ترجمه‌ی خطای HTTP بر اساس کد وضعیت — پیام‌های معنادار بک‌اند حفظ می‌شوند // ترجمه‌ی خطای HTTP بر اساس کد وضعیت
export const faHttpError = (status: number, fallback: string): string => { const faHttpError = (status: number, fallback: string): string => {
if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است"; if (status === 413) return "فایل انتخاب‌شده از حد مجاز (۵ مگابایت) بزرگ‌تر است";
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد"; if (status === 404) return "مورد درخواستی یافت نشد";
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر"; if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "داده‌ی ارسال‌شده نامعتبر است"; if (status === 400) 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": ...}
// ============================================================ // ============================================================
@@ -184,43 +163,27 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
headers: authHeaders(init), headers: authHeaders(init),
}); });
} catch { } catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0); throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟");
} }
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) { if (body?.error?.message) {
errCode = body.error.code; msg = body.error.message;
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 loc = first?.loc || []; const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : "";
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)) : "داده‌ی ارسال‌شده نامعتبر است";
const fName = fieldName ? faField(fieldName) : ""; msg = field ? `داده‌ی «${faField(field)}» نامعتبر است: ${fa}` : fa;
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 نیست */
} }
const finalMsg = faHttpError(res.status, msg); throw new Error(faHttpError(res.status, msg));
throw new ApiError(finalMsg, res.status, errCode, errField);
} }
return (await res.json()) as T; return (await res.json()) as T;
} }
@@ -705,66 +668,23 @@ 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 = toAsciiDigits(v).trim(); const str = String(v).trim();
if (!str) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
// 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 {
if (!v) return null; const d = toDateOnly(v);
const str = toAsciiDigits(v).trim(); if (!d) return null;
if (!str) return null; return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
// ۱. تاریخ جلالی با زمان اختیاری
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);
} }
export function looksLikeUuid(v: string | null | undefined): boolean { function looksLikeUuid(v: string): boolean {
if (!v) return false; return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
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());
} }
// ============================================================ // ============================================================
@@ -857,13 +777,6 @@ 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);
@@ -957,6 +870,7 @@ 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) => ({
@@ -985,7 +899,11 @@ 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 ---
@@ -1341,10 +1259,6 @@ 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 });
@@ -1476,12 +1390,8 @@ 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) => { .map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10))
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);
+5 -21
View File
@@ -99,11 +99,8 @@ export const MONTHS = [
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"]; export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
export function toAsciiDigits(s: string | number | null | undefined): string { function toEnSafe(s: string): string {
if (s == null) return ""; return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
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 "۱۴۰۳/۰۵/۱۲"
@@ -124,30 +121,17 @@ 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" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string // parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
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 s = toAsciiDigits(str).trim(); const nums = String(str)
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(x, 10)); .map((x) => parseInt(toEnSafe(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 {
+21 -4
View File
@@ -147,10 +147,17 @@ 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") {
return li.target_project?.name ? [li.target_project.name] : []; if (li.target_project?.name) {
return [li.target_project.name];
} }
// For 'offered', 'rejected', 'expired': return all candidate projects // اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تک‌پروژه بودن
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean); if (li.projects && li.projects.length === 1) {
const p = li.projects[0];
return [typeof p === "string" ? p : p.name];
}
return [];
}
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
} }
// ---------- واحد مرجع و ضریب تبدیل ---------- // ---------- واحد مرجع و ضریب تبدیل ----------
@@ -281,8 +288,13 @@ export function filterQuotes(
"پیشنهاد شده": "offered", "پیشنهاد شده": "offered",
"پذیرفته شده": "accepted", "پذیرفته شده": "accepted",
"رسیده": "arrived", "رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
}; };
const stKey = STATUS_BY_LABEL[stVal] || ""; const stKey = STATUS_BY_LABEL[stVal] || 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);
@@ -306,11 +318,16 @@ 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,
+96 -73
View File
@@ -36,7 +36,6 @@ 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();
@@ -51,13 +50,10 @@ 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");
@@ -70,7 +66,6 @@ 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);
@@ -86,7 +81,6 @@ 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;
@@ -109,7 +103,6 @@ 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));
@@ -147,10 +140,8 @@ 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);
@@ -164,11 +155,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
if (!cardMeta.size) { if (!cardMeta.size) {
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است."); toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
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;
}); });
@@ -178,7 +168,6 @@ 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) => {
@@ -200,7 +189,6 @@ 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];
@@ -210,7 +198,6 @@ 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);
@@ -219,14 +206,18 @@ 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";
}); });
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛ const projUnion = new Set<string>();
// 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;
@@ -242,26 +233,37 @@ 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-2xl"> <DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
<DialogHeader> <div className="flex flex-col max-h-[85vh] w-full">
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
</DialogHeader>
<div className="text-[12px] text-muted"> {/* هدر ثابت و هم‌تراز */}
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید. <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>
<p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
</p>
</div> </div>
{/* کارت‌های termsheet */} {/* بدنه اسکرول‌پذیر */}
<div className="flex flex-col gap-2"> <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
{/* بخش ۱: تنظیم شرایط پرداخت */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
<div className="flex flex-col gap-4">
{cards.map((c, idx) => ( {cards.map((c, idx) => (
<div key={c.domId} className="modal-variant"> <div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
<div className="modal-variant-head"> <div className="modal-variant-head mb-2 flex items-center justify-between">
<span className="variant-num">{cardLabel(c, idx)}</span> <span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}> <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> </button>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
{c.milestones.map((m, i) => ( {c.milestones.map((m, i) => (
<MilestoneRow <MilestoneRow
key={i} key={i}
@@ -275,31 +277,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/> />
))} ))}
</div> </div>
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}> <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> </Button>
</div> </div>
))} ))}
</div> </div>
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}> <Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
+ نوشتن conditions جدید + افزودن شرایط پرداخت جدید
</Button> </Button>
</section>
{/* تخصیص قلم‌ها */} {/* بخش ۲: تخصیص اقلام */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به conditions:</div> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-4 text-[12px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود. قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div> </div>
<div> <div className="flex flex-col gap-2">
{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 key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}> <div
key={li.id}
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
>
<input <input
type="checkbox" type="checkbox"
className="accent-accent" className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
checked={!removed} checked={!removed}
title="تیک = در معامله می‌ماند" title="تیک = در معامله می‌ماند"
onChange={(e) => { onChange={(e) => {
@@ -309,16 +315,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(next); setRemovedItems(next);
}} }}
/> />
<span className="flex-1 text-[13px]"> <div className="flex-1 min-w-[120px]">
<div className="text-[13.5px] font-medium text-slate-800">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</span> </div>
<span className="text-[12px] text-muted"> <div className="text-[12px] text-muted mt-0.5">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
</span> </div>
</div>
<select <select
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" 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'}`}
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}>
@@ -330,35 +340,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
); );
})} })}
{active.length === 0 && ( {active.length === 0 && (
<div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div> <div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
<span>⚠️</span> همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
</div>
)} )}
</div> </div>
</div> </section>
{/* تخصیص قلم‌ها به پروژه */} {/* بخش ۳: تخصیص اقلام به پروژه */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-4 text-[12px] text-muted">
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت. برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div> </div>
<div className="flex flex-col gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{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-2.5"> <div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
<div className="mb-1.5 text-[12.5px]"> <div className="mb-3 flex items-center justify-between">
<span className="font-semibold"> <span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</span> </span>
<span className="ms-2 text-[11px] text-muted"> <span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit} {fmtNum(li.quantity)} {li.unit}
</span> </span>
</div> </div>
{projectOptions.length ? ( {projectOptions.length ? (
<select <select
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" 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"
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 }))
@@ -376,17 +388,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
))} ))}
</select> </select>
) : ( ) : (
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div> <div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)} )}
</div> </div>
); );
})} })}
</div> </div>
</div> </section>
{/* فاکتورهای اصلی */} {/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -401,9 +414,10 @@ 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="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div> <div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<FileList <FileList
files={proformas} files={proformas}
onChange={setProformas} onChange={setProformas}
@@ -419,20 +433,29 @@ 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="field max-w-xs"> <div className="max-w-xs">
<label>تاریخ و زمان پذیرش</label> <label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime /> <JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div> </div>
</section>
</div> </div>
<DialogFooter> {/* فوتر ثابت */}
<Button variant="slate" onClick={onClose}> <div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف انصراف
</Button> </Button>
<Button onClick={confirm}>ثبت معامله بسته‌شده</Button> <Button onClick={confirm} className="w-full sm:w-auto">
ثبت معامله بسته‌شده
</Button>
</DialogFooter> </DialogFooter>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+23 -9
View File
@@ -11,9 +11,8 @@ 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 } from "../../components/StatsBar"; import { StatsBar, type StatusFilter } 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";
@@ -56,18 +55,31 @@ 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 leadTimes = quotes const arrived = quotes.filter((q) => q.status === "arrived").length;
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date) return { total, offered, accepted, arrived };
.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]);
@@ -119,7 +131,9 @@ export function DashboardPage() {
total={stats.total} total={stats.total}
offered={stats.offered} offered={stats.offered}
accepted={stats.accepted} accepted={stats.accepted}
avgLeadTime={stats.avgLead} arrived={stats.arrived}
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/> />
<FilterToolbar <FilterToolbar
filters={filters} filters={filters}
+332 -197
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, AlertCircle } from "lucide-react"; import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } 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,7 +37,6 @@ 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[];
@@ -53,7 +52,7 @@ interface EditItemRow {
interface EditTsCard { interface EditTsCard {
key: string; key: string;
domId: string; // "" یا "new-ts-..." برای کارت جدید domId: string;
milestones: Milestone[]; milestones: Milestone[];
} }
@@ -62,7 +61,6 @@ 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();
@@ -84,6 +82,7 @@ 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("");
@@ -113,7 +112,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} }
}; };
// گزینه‌های مشخصه
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]); const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -134,7 +132,6 @@ 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));
@@ -149,17 +146,7 @@ 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) => {
@@ -167,7 +154,6 @@ 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),
@@ -197,7 +183,6 @@ 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;
@@ -233,7 +218,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "", unit: "",
price: null, price: null,
total: null, total: null,
tsDomIds: [], tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
acceptedTsId: null, acceptedTsId: null,
rejected: false, rejected: false,
restore: true, restore: true,
@@ -264,7 +249,6 @@ 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);
@@ -282,7 +266,6 @@ 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) => {
@@ -299,7 +282,6 @@ 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) {
@@ -361,15 +343,8 @@ 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: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1), id: "li-" + (newItems.length + 1),
material_id: materialId, material_id: materialId,
quantity: qty, quantity: qty,
unit, unit,
@@ -379,7 +354,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: candProjs, projects: row.origLi?.projects ?? [],
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),
}); });
@@ -395,7 +370,6 @@ 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;
@@ -435,23 +409,103 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return ( return (
<> <>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6"> <DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
<DialogHeader> {/* هدر سفید و تمیز */}
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle> <DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div>
</DialogHeader> </DialogHeader>
{hasReceipts && ( {hasReceipts && (
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber"> <div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<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.5"> <div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-muted">تأمین‌کننده</label> <label className="text-xs font-semibold text-gray-600">تأمین‌کننده</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)}
@@ -462,11 +516,9 @@ 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.5"> <div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-muted">پروژه</label> <label className="text-xs font-semibold text-gray-600">پروژه</label>
<div className="w-full min-w-0">
<MultiSelectCombobox <MultiSelectCombobox
values={selectedProjects} values={selectedProjects}
onChange={setSelectedProjects} onChange={setSelectedProjects}
@@ -476,67 +528,82 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2"> <div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-muted">یادداشت</label> <label className="text-xs font-semibold text-gray-600">یادداشت</label>
<textarea <textarea
rows={2} rows={2}
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" 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"
value={notes} value={notes}
placeholder="یادداشت‌های داخلی این معامله..."
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* اقلام */} {/* لیست اقلام */}
<div className="border-t border-line pt-3"> <div className="space-y-3">
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div> <div className="flex items-center justify-between">
<div className="flex flex-col gap-3"> <span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
{itemRows.map((row) => ( </div>
{itemRows.map((row, idx) => (
<div <div
key={row.key} key={row.key}
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`} className={cn(
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
)}
> >
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4"> <div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
{/* متریال — تمام‌عرض در موبایل */} <div className="flex items-center gap-1.5 min-w-0">
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1"> <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">
<label className="text-[11px] font-semibold text-muted">متریال</label> {faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900 truncate">
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
</span>
</div>
{!hasReceipts && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
<input <input
disabled={hasReceipts} 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" 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="متریال" placeholder="نام متریال"
value={row.cat} value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })} onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/> />
</div> </div>
{/* برند */} <div className="flex flex-col gap-1">
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <label className="text-[11px] font-semibold text-gray-600">برند</label>
<label className="text-[11px] font-semibold text-muted">برند</label>
<input <input
disabled={hasReceipts} 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" 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="برند" 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="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1"> <div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-muted">مشخصه</label> <label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
<div className="w-full min-w-0">
<MultiSelectCombobox <MultiSelectCombobox
disabled={hasReceipts} disabled={hasReceipts}
values={row.specs} values={row.specs}
@@ -545,11 +612,23 @@ 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="col-span-1 flex min-w-0 flex-col gap-1"> <div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
<label className="text-[11px] font-semibold text-muted">تعداد</label> <div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.qty} value={row.qty}
@@ -563,9 +642,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/> />
</div> </div>
{/* قیمت واحد */} <div className="flex flex-col gap-1">
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.price} value={row.price}
@@ -575,14 +653,13 @@ 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">
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.total} value={row.total}
@@ -593,48 +670,45 @@ 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="mt-2.5 border-t border-line/60 pt-2"> <div className="pt-2 border-t border-gray-100">
{row.rejected ? ( {row.rejected ? (
<label className="ts-cb-label e-restore"> <label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<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">
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div> <span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{tsCards.map((card, idx) => ( {tsCards.map((card, cIdx) => {
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}> const isSelected = row.tsDomIds.includes(card.domId || card.key);
return (
<label
key={card.key}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
isSelected
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
hasReceipts && "cursor-not-allowed opacity-60",
)}
>
<input <input
type="checkbox" type="checkbox"
disabled={hasReceipts} disabled={hasReceipts}
className="me-1 accent-accent disabled:cursor-not-allowed" className="accent-blue-600 h-3.5 w-3.5 rounded"
checked={row.tsDomIds.includes(card.domId || card.key)} checked={isSelected}
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;
@@ -643,73 +717,170 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
patchItem(row.key, { tsDomIds: [...next] }); patchItem(row.key, { tsDomIds: [...next] });
}} }}
/> />
{tsLabel(card, idx)} <span>{tsLabel(card, cIdx)}</span>
</label> </label>
))} );
})}
</div>
</div> </div>
</>
)} )}
</div> </div>
</div> </div>
))}
</div> </div>
))}
{!hasReceipts && ( {!hasReceipts && (
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}> <Button
+ افزودن قلم type="button"
variant="line"
size="sm"
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
onClick={addItemRow}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن قلم جدید</span>
</Button>
)}
</div>
</div>
)}
{/* تب ۲: شرایط پرداخت */}
{activeTab === "terms" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between">
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="gap-1 text-xs text-gray-700"
onClick={addTsCard}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن گزینه جدید</span>
</Button> </Button>
)} )}
</div> </div>
{/* termsheetها */} <div className="flex flex-col gap-3">
<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 key={card.key} className="variant-card"> <div
<div className="variant-head"> key={card.key}
<span className="variant-num">{tsLabel(card, idx)}</span> className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
{!hasReceipts && ( >
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}> <div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
× <div className="flex items-center gap-1.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
<span className="text-[11px] text-gray-500 font-medium">
({faNum(card.milestones.length)} مرحله)
</span>
</div>
{!hasReceipts && tsCards.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف گزینه"
onClick={() => removeTsCard(card.key)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</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, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) }) updateTs(card.key, {
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="mt-1" className="w-full sm:w-auto text-xs text-gray-700"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })} onClick={() =>
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>
{!hasReceipts && ( </div>
<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="border-t border-line pt-3"> <div className="text-xs font-bold text-gray-900">تاریخ‌های چرخه خرید</div>
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوست‌ها</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -723,70 +894,32 @@ 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="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between"> <DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
{q.status === "offered" ? ( {q.status === "offered" ? (
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="w-full gap-1.5 sm:w-auto" className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
<span>حذف پیشنهاد</span> <span className="hidden sm:inline">حذف</span>
</Button> </Button>
) : ( ) : (
<div /> <div />
)} )}
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial"> <div className="flex items-center gap-2">
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
انصراف انصراف
</Button> </Button>
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial"> <Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
ذخیره تغییرات ذخیره تغییرات
</Button> </Button>
</div> </div>
@@ -794,19 +927,20 @@ 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"> <DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader> <DialogHeader>
<DialogTitle>حذف پیشنهاد خرید</DialogTitle> <DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2 text-sm text-muted"> <div className="py-2 text-xs sm:text-sm text-gray-600">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است. آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</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}
> >
@@ -815,6 +949,7 @@ 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}
> >
+43 -11
View File
@@ -1,7 +1,6 @@
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;
@@ -10,8 +9,33 @@ export interface Filters {
search: string; search: string;
} }
// لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری // گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]); const STATUS_OPTIONS = [
{ id: "offered", label: "پیشنهاد باز" },
{ id: "accepted", label: "پذیرفته‌شده" },
{ id: "arrived", label: "تحویل‌شده" },
];
const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز",
accepted: "پذیرفته‌شده",
arrived: "تحویل‌شده",
};
const STATUS_TO_KEY: Record<string, string> = {
"پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تحویل": "arrived",
offered: "offered",
accepted: "accepted",
arrived: "arrived",
};
export interface FilterToolbarProps { export interface FilterToolbarProps {
filters: Filters; filters: Filters;
@@ -40,6 +64,14 @@ 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) ===== */}
@@ -148,12 +180,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={filters.status} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_ITEMS} items={STATUS_OPTIONS}
allowAdd={false} allowAdd={false}
placeholder="همه وضعیت‌ها" placeholder="همه وضعیت‌ها"
onInputChange={(v) => set({ status: v })} onInputChange={handleStatusChange}
/> />
</div> </div>
</div> </div>
@@ -197,12 +229,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={filters.status} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_ITEMS} items={STATUS_OPTIONS}
allowAdd={false} allowAdd={false}
placeholder="همه" placeholder="همه"
onInputChange={(v) => set({ status: v })} onInputChange={handleStatusChange}
/> />
</div> </div>
<div className="field min-w-[200px] flex-1"> <div className="field min-w-[200px] flex-1">
+1 -26
View File
@@ -243,32 +243,7 @@ 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>
+28 -4
View File
@@ -7,6 +7,7 @@ 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";
@@ -15,7 +16,6 @@ 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,12 +36,29 @@ 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);
@@ -69,7 +86,6 @@ 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,
@@ -101,7 +117,15 @@ 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 key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} /> <SupplierCard
key={s.id}
s={s}
topic={cat}
deletable
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
/>
))} ))}
</div> </div>
)} )}
+19 -5
View File
@@ -1,7 +1,9 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react"; import { MapPin, Phone, User, Plus, Timer } 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
@@ -19,33 +21,45 @@ 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"> <div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link"> <Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
{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 data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}> <div className={cn("field max-w-md", invalid && "invalid")}>
<label>یا نام تأمین‌کننده را تایپ و انتخاب کنید</label> <label>یا نام تأمین‌کننده را تایپ و انتخاب کنید</label>
<Combobox <Combobox
value={supplierName} value={supplierName}
+1 -1
View File
@@ -221,7 +221,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<span>قیمت کل</span> <span>قیمت کل</span>
</div> </div>
<div data-field="line_items" className="flex flex-col gap-2"> <div 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 data-field="termsheets" className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{cards.map((card, idx) => ( {cards.map((card, idx) => (
<TermsheetCard <TermsheetCard
key={card.key} key={card.key}
+1 -1
View File
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
}; };
return ( return (
<div data-field="project"> <div>
<h3 className="mb-1 text-[15px] font-bold"> <h3 className="mb-1 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span> <span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
پروژه‌ها پروژه‌ها
+4 -4
View File
@@ -57,20 +57,20 @@ export function Step5Review({
<div className="grid max-w-2xl grid-cols-12 gap-3"> <div className="grid max-w-2xl grid-cols-12 gap-3">
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */} {/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date"> <div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
<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" data-field="expiry_date"> <div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
<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" data-field="notes"> <div className="field col-span-12">
<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" data-field="proforma"> <div className="field col-span-12">
<label>پیش‌فاکتور (اختیاری)</label> <label>پیش‌فاکتور (اختیاری)</label>
{proforma ? ( {proforma ? (
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */ /* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
+31 -77
View File
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react"; import { ChevronRight, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types"; import type { LineItem, Quote } from "../../lib/types";
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api"; import { api, nextId } from "../../lib/api";
import { import {
useAddMaterial, useAddMaterial,
useAddProject, useAddProject,
@@ -125,8 +125,16 @@ 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ها ----------
@@ -185,72 +193,19 @@ export function WizardPage() {
// ---------- ذخیره ---------- // ---------- ذخیره ----------
const doSave = async () => { const doSave = async () => {
const resolveErrorTarget = (err: unknown): { step: number; field?: string } => { const sectionError = (step: number, title: string, err: unknown) => {
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}`, {
duration: 8000, action: { label: "رفع خطا", onClick: () => goToStep(step) },
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 = "";
@@ -289,11 +244,10 @@ 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) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName)); let material = materials.find((m) => 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),
@@ -303,11 +257,11 @@ export function WizardPage() {
// تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند. // تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند.
if (!material && cat) { if (!material && cat) {
material = materials.find( material = materials.find(
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand), (m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
); );
} }
let materialId: string; let materialId: string;
if (material && looksLikeUuid(material.id)) { if (material) {
materialId = material.id; materialId = material.id;
} else { } else {
try { try {
@@ -318,7 +272,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) {
@@ -407,8 +361,8 @@ export function WizardPage() {
toast.success("پیشنهاد ذخیره شد."); toast.success("پیشنهاد ذخیره شد.");
navigate("/"); navigate("/");
} catch (e) { } catch (e) {
const target = resolveErrorTarget(e); const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
sectionError(target, "خطا در ثبت پیشنهاد", e); sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
} }
}; };
@@ -438,7 +392,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 data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step1TopicSupplier <Step1TopicSupplier
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
onTopicSelect={(t) => { onTopicSelect={(t) => {
@@ -462,12 +416,12 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div 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 data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step3Termsheets <Step3Termsheets
cards={cards} cards={cards}
onCardsChange={setCards} onCardsChange={setCards}
@@ -478,7 +432,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step4Projects <Step4Projects
items={items} items={items}
projects={projectNames} projects={projectNames}
@@ -492,7 +446,7 @@ export function WizardPage() {
</div> </div>
)} )}
{currentStep === 5 && ( {currentStep === 5 && (
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step5Review <Step5Review
quoteDate={quoteDate} quoteDate={quoteDate}
onQuoteDate={setQuoteDate} onQuoteDate={setQuoteDate}