Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416683b3d7 | ||
|
|
bc86d35e1e | ||
|
|
8c5f4ab770 | ||
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f | ||
|
|
d69ec7f7a9 | ||
|
|
34290b9371 | ||
|
|
cff35e3660 | ||
|
|
b469238153 | ||
|
|
2fd72fecf7 | ||
|
|
ae62a0391b | ||
|
|
7057ac4f96 | ||
|
|
36414863d4 | ||
|
|
baa32a2652 | ||
|
|
14a4a29431 | ||
|
|
d2b9af5278 | ||
|
|
e637d80b76 | ||
|
|
3c7ae80b02 | ||
|
|
de177f3520 | ||
|
|
b6cbcc30a5 | ||
|
|
08ec341c52 | ||
|
|
b5dc3e585a | ||
|
|
65758b5e78 | ||
|
|
b1edf6eed3 | ||
|
|
a181a8753d | ||
|
|
8adfd2267f |
@@ -1,3 +1,4 @@
|
||||
|
||||
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
||||
|
||||
Self-contained ship package: everything needed to run the stack is in this
|
||||
@@ -12,7 +13,9 @@ folder — no external files or sibling folders required.
|
||||
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
|
||||
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
|
||||
|
||||
|
||||
```
|
||||
|
||||
Browser ──► Traefik :80 (edge)
|
||||
│
|
||||
├── Host(localhost) ──► frontend (nginx SPA :80)
|
||||
@@ -25,6 +28,7 @@ Browser ──► Traefik :80 (edge)
|
||||
│
|
||||
├── postgres :5432 (procurement + casdoor DBs)
|
||||
└── minio :9000 (invoice/proforma files)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -74,6 +78,7 @@ v1.3/
|
||||
├── Projects/ (projects)
|
||||
├── Units/ (unit conversions)
|
||||
└── Inventory/ (warehouse management panel — React port)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -86,9 +91,9 @@ the form (username / password) is sent to
|
||||
(password grant), verifies the returned JWT, and starts the session —
|
||||
no browser redirect to Casdoor.
|
||||
|
||||
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
||||
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
||||
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
||||
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
||||
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
||||
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
||||
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
||||
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
||||
@@ -111,11 +116,11 @@ for production builds).
|
||||
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
||||
Casdoor organization, application and users (idempotent). Full details —
|
||||
addresses, credentials, role model, failure scenarios and the checklist —
|
||||
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
|
||||
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
|
||||
|
||||
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
||||
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
|
||||
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
|
||||
|
||||
---
|
||||
|
||||
@@ -131,7 +136,7 @@ at these public addresses (HTTPS):
|
||||
|
||||
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||
assigned inside the app (`users.role` — see «Login» above and
|
||||
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
|
||||
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
|
||||
|
||||
### MinIO console on the server (SSH tunnel)
|
||||
|
||||
@@ -141,20 +146,19 @@ is an SSH tunnel from your machine:
|
||||
|
||||
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||
use the panel):
|
||||
|
||||
```powershell
|
||||
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||
|
||||
```
|
||||
|
||||
|
||||
You will also land in a normal SSH session — that is fine; the tunnel
|
||||
lives as long as the window stays open.
|
||||
|
||||
2. In your browser, open: **http://localhost:9001**
|
||||
|
||||
3. Log in with the production MinIO root credentials from the server's
|
||||
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||
**never** written in this repo or its history. See
|
||||
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
|
||||
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
|
||||
|
||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||
ERP stores, browsable and downloadable from there.
|
||||
@@ -168,11 +172,11 @@ tunnel.
|
||||
|
||||
Notes from the server setup:
|
||||
|
||||
- The panel is published only on the server's loopback, so the internet
|
||||
* The panel is published only on the server's loopback, so the internet
|
||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||
* Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||
why the console uses `9001`.
|
||||
- If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||
* If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||
with a password prompt on top), that can be arranged on request — the
|
||||
tunnel remains the safer default.
|
||||
@@ -181,9 +185,9 @@ Notes from the server setup:
|
||||
|
||||
## Prerequisites (target machine)
|
||||
|
||||
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||
- Internet access on first build (image + `npm ci` downloads)
|
||||
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||
* Internet access on first build (image + `npm ci` downloads)
|
||||
|
||||
---
|
||||
|
||||
@@ -192,6 +196,7 @@ Notes from the server setup:
|
||||
```powershell
|
||||
cd <this-folder>
|
||||
docker compose up -d --build
|
||||
|
||||
```
|
||||
|
||||
First run downloads base images and builds the frontend (takes a few minutes).
|
||||
@@ -201,6 +206,7 @@ Wait until all services are up:
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
|
||||
```
|
||||
|
||||
All should show `Up` (postgres also `(healthy)`).
|
||||
@@ -225,11 +231,12 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
|
||||
```powershell
|
||||
backend\tools\smoke_test.py # read paths
|
||||
backend\tools\smoke_test_write.py / smoke_test_misc.py
|
||||
|
||||
```
|
||||
|
||||
> Every API route now requires a Casdoor JWT — a bare
|
||||
> `curl http://localhost/api/v1/quotes` returns **401** by design.
|
||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
|
||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -241,7 +248,7 @@ backend\tools\smoke_test_write.py / smoke_test_misc.py
|
||||
| Web console | http://localhost:9001 |
|
||||
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
||||
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
|
||||
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
|
||||
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
|
||||
|
||||
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
|
||||
|
||||
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
|
||||
|
||||
```powershell
|
||||
.\mc.cmd ls --recursive
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -261,10 +269,12 @@ Port: 5433
|
||||
Database: procurement
|
||||
Username: postgres
|
||||
Password: postgres
|
||||
|
||||
```
|
||||
|
||||
```powershell
|
||||
docker exec -it procurement-postgres psql -U postgres -d procurement
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -277,22 +287,24 @@ material). Two supported ways to bring a fresh stack up:
|
||||
|
||||
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|
||||
`postgres_data` volume; then run the seeders:
|
||||
|
||||
```powershell
|
||||
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
||||
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||
# Demo dataset (optional):
|
||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||
|
||||
```
|
||||
|
||||
|
||||
2. **Restore your own dump** — create dumps on the server (kept server-side,
|
||||
e.g. `/srv/erp/backups/`), never in git:
|
||||
|
||||
```powershell
|
||||
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
|
||||
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
||||
|
||||
```
|
||||
|
||||
|
||||
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||
first — this **replaces all data**). The scheduled backup sidecar
|
||||
(see `docs/OPERATIONS.md`) automates this.
|
||||
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
|
||||
docker compose down # stop (keep data)
|
||||
docker compose up -d --build # rebuild after code changes
|
||||
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known behavior
|
||||
|
||||
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||
every API route** except `/health`, `/health/ready` and `/auth/login`
|
||||
(401 without a token, 403 without a role). The former dev-header fallback
|
||||
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
||||
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
||||
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||
the procurement manager can only assign projects to project managers.
|
||||
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
||||
grant/revoke «مدیر تدارکات»).
|
||||
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
||||
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
||||
at least one item with a valid quantity is enough to submit; other items
|
||||
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
||||
badges and a «رسید جزیی» badge on the quote.
|
||||
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
||||
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
||||
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
||||
dumped/restored; see `docs/casdoor-auth.md`).
|
||||
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
||||
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
||||
serves the SPA and proxies `/api` internally. Production (real domains,
|
||||
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
||||
(see the prod file header for the required env vars).
|
||||
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
||||
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
||||
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
||||
for the units-dashboard tables and `quote_attachments` at startup.
|
||||
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||
invoice/proforma download links from MinIO.
|
||||
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
||||
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||
* **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامینکنندهای برای این متریال ثبت نشده است») if none exist.
|
||||
* If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
||||
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||
reachable; check `docker compose ps` and `docker compose logs api`.
|
||||
|
||||
```
|
||||
|
||||
+41
-25
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_DASHBOARD_DDL = [
|
||||
# One reference unit per category (units.html «واحد مرجع هر دسته»)
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS category_reference_units (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||
ref_unit TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
|
||||
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS unit_conversions (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||
brand TEXT,
|
||||
from_unit TEXT NOT NULL,
|
||||
to_unit TEXT NOT NULL,
|
||||
factor NUMERIC(18,6) NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
|
||||
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
|
||||
""",
|
||||
@@ -345,6 +326,18 @@ BASE_UNITS: list[dict] = [
|
||||
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
||||
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
||||
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
||||
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
|
||||
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
|
||||
]
|
||||
|
||||
# تبدیلهای ثابت جهانی («تبدیلهای ثابت — همه متریالها») — ضرایب استاندارد و
|
||||
# فراگیر که در هر استارتآپ بهصورت idempotent ذخیره میشوند. کلید یکتا:
|
||||
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
|
||||
# قالبی که فرانتاند در standard_conversions مینویسد.
|
||||
STANDARD_CONVERSIONS: list[dict] = [
|
||||
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
|
||||
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
|
||||
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
|
||||
]
|
||||
|
||||
|
||||
@@ -379,13 +372,20 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
||||
u,
|
||||
)
|
||||
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
|
||||
# Seed the global fixed conversions (idempotent — unique key from/to unit).
|
||||
for c in STANDARD_CONVERSIONS:
|
||||
await run_statement(
|
||||
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
|
||||
"VALUES (:from_unit, :to_unit, :factor) "
|
||||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||
c,
|
||||
)
|
||||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||
await run_statement("""
|
||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
||||
AFTER INSERT ON supplier_materials
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
||||
""")
|
||||
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||
await run_statement("""
|
||||
CREATE TRIGGER trg_item_decisions_sync_price
|
||||
@@ -394,6 +394,22 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
""")
|
||||
|
||||
|
||||
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||||
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
|
||||
|
||||
These three tables backed features that were never populated (0 rows on every
|
||||
volume): per-category reference units and conversion factors (the
|
||||
per-reference-unit price line) and the category -> attribute mapping. Dropping
|
||||
them is idempotent and loses no data. material_categories itself stays — every
|
||||
supplier carries one as its label.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
|
||||
))
|
||||
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||||
|
||||
|
||||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||
ddl = """
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.db.engine import AsyncSessionLocal, engine
|
||||
from app.db.init_db import (
|
||||
ensure_attachment_table,
|
||||
ensure_audit_table,
|
||||
ensure_drop_category_tables,
|
||||
ensure_dashboard_tables,
|
||||
ensure_material_topic_column,
|
||||
ensure_quote_projects,
|
||||
@@ -105,6 +106,7 @@ async def lifespan(_app: FastAPI):
|
||||
await ensure_quote_projects(engine)
|
||||
await ensure_v26(engine)
|
||||
await ensure_audit_table(engine)
|
||||
await ensure_drop_category_tables(engine)
|
||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||
try:
|
||||
yield
|
||||
@@ -227,10 +229,16 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
|
||||
# pm, users) keep them — the global dependency guarantees nothing is open
|
||||
# by omission.
|
||||
from app.routers.auth import router as _auth_router # noqa: E402
|
||||
# The files router enforces its own access check per route: a browser cannot
|
||||
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
||||
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
||||
from app.routers.files import router as _files_router # noqa: E402
|
||||
|
||||
for router in ROUTERS:
|
||||
if router is _auth_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
elif router is _files_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
else:
|
||||
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
|
||||
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("material_categories.id", ondelete="RESTRICT")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||
canonical_unit_id: Mapped[int] = mapped_column(
|
||||
@@ -149,7 +146,6 @@ class Material(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||
back_populates="material", lazy="raise", passive_deletes=True
|
||||
|
||||
@@ -4,8 +4,6 @@ from app.routers import (
|
||||
auth,
|
||||
brand_materials,
|
||||
brands,
|
||||
category_attributes,
|
||||
category_ref_units,
|
||||
files,
|
||||
inventory,
|
||||
item_decisions,
|
||||
@@ -24,7 +22,6 @@ from app.routers import (
|
||||
supplier_materials,
|
||||
suppliers,
|
||||
termsheets,
|
||||
unit_conversions,
|
||||
units,
|
||||
users,
|
||||
)
|
||||
@@ -35,7 +32,6 @@ ROUTERS = [
|
||||
brands.router,
|
||||
material_categories.router,
|
||||
attribute_definitions.router,
|
||||
category_attributes.router,
|
||||
materials.router,
|
||||
brand_materials.router,
|
||||
suppliers.router,
|
||||
@@ -51,8 +47,6 @@ ROUTERS = [
|
||||
item_receipts.router,
|
||||
quote_events.router,
|
||||
files.router,
|
||||
category_ref_units.router,
|
||||
unit_conversions.router,
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Category attributes — scoped attribute definitions per category (P2 set).
|
||||
|
||||
Identified by composite key {category_id}/{attribute_definition_id}.
|
||||
The nested `attribute` object is ALWAYS included (not expand-based).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
|
||||
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/material-categories/{category_id}/attributes",
|
||||
tags=["category-attributes"],
|
||||
)
|
||||
|
||||
|
||||
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
|
||||
return {
|
||||
"id": str(ca.id),
|
||||
"category_id": str(ca.category_id),
|
||||
"attribute_definition_id": str(ca.attribute_definition_id),
|
||||
"is_required": ca.is_required,
|
||||
"is_filterable": ca.is_filterable,
|
||||
"conditional_rules": ca.conditional_rules,
|
||||
"sort_order": ca.sort_order,
|
||||
"attribute": {
|
||||
"attribute_key": attribute.attribute_key,
|
||||
"label": attribute.label,
|
||||
"data_type": attribute.data_type,
|
||||
"unit_id": attribute.unit_id,
|
||||
"enum_values": attribute.enum_values,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
return category
|
||||
|
||||
|
||||
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
|
||||
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
|
||||
return ca_to_dict(ca, attribute)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_category_attributes(
|
||||
category_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _category_or_404(db, category_id)
|
||||
stmt = (
|
||||
select(CategoryAttribute)
|
||||
.where(CategoryAttribute.category_id == category_id)
|
||||
.order_by(CategoryAttribute.sort_order.asc())
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category_attribute(
|
||||
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
await _category_or_404(db, category_id)
|
||||
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
|
||||
duplicate = await db.scalar(
|
||||
select(CategoryAttribute.id).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این ویژگی قبلاً به این دستهبندی اضافه شده است", field="attribute_definition_id")
|
||||
ca = CategoryAttribute(
|
||||
category_id=category_id,
|
||||
attribute_definition_id=payload.attribute_definition_id,
|
||||
is_required=payload.is_required,
|
||||
is_filterable=payload.is_filterable,
|
||||
conditional_rules=payload.conditional_rules,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(ca)
|
||||
await db.flush()
|
||||
return {"data": await _with_attribute(db, ca)}
|
||||
|
||||
|
||||
@router.patch("/{attribute_definition_id}")
|
||||
async def update_category_attribute(
|
||||
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ca = await db.scalar(
|
||||
select(CategoryAttribute).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if ca is None:
|
||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
||||
if payload.is_required is not None:
|
||||
ca.is_required = payload.is_required
|
||||
if payload.is_filterable is not None:
|
||||
ca.is_filterable = payload.is_filterable
|
||||
if payload.conditional_rules is not None:
|
||||
ca.conditional_rules = payload.conditional_rules
|
||||
if payload.sort_order is not None:
|
||||
ca.sort_order = payload.sort_order
|
||||
await db.flush()
|
||||
return {"data": await _with_attribute(db, ca)}
|
||||
|
||||
|
||||
@router.delete("/{attribute_definition_id}")
|
||||
async def delete_category_attribute(
|
||||
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ca = await db.scalar(
|
||||
select(CategoryAttribute).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if ca is None:
|
||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
||||
await db.delete(ca)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Category reference units — units.html dashboard (P2-26/27).
|
||||
|
||||
One reference unit per category (UNIQUE category_id); PUT is an upsert.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
|
||||
from app.schemas.catalog import CategoryReferenceUnitPut
|
||||
from app.services.expand_service import category_brief
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
|
||||
|
||||
|
||||
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(cru.id),
|
||||
"category_id": str(cru.category_id),
|
||||
"ref_unit": cru.ref_unit,
|
||||
"updated_at": to_iso(cru.updated_at),
|
||||
}
|
||||
if "category" in expand and hasattr(cru, "_category"):
|
||||
d["category"] = category_brief(cru._category)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_category_reference_units(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(CategoryReferenceUnit)
|
||||
if category_id:
|
||||
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "category" in tokens:
|
||||
cat_ids = {cru.category_id for cru in items}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
for cru in items:
|
||||
cru._category = cats.get(cru.category_id)
|
||||
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
|
||||
|
||||
|
||||
@router.put("/{category_id}")
|
||||
async def upsert_category_reference_unit(
|
||||
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
|
||||
if cru is None:
|
||||
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
|
||||
db.add(cru)
|
||||
else:
|
||||
cru.ref_unit = payload.ref_unit
|
||||
await db.flush()
|
||||
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
|
||||
@@ -17,14 +17,15 @@ import os
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.auth import UserContext, get_current_user, get_optional_user, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote
|
||||
from app.services import file_service
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
|
||||
from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||
|
||||
@@ -41,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
|
||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _content_disposition(file_name: str) -> str:
|
||||
def _content_disposition(file_name: str, inline: bool = False) -> str:
|
||||
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
|
||||
|
||||
HTTP headers are encoded as latin-1, so a Persian name raises
|
||||
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
|
||||
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
|
||||
in ``filename`` for old clients.
|
||||
|
||||
``inline=True`` makes the browser render the file (PDF in a viewer,
|
||||
image in the tab) instead of downloading it — required by the preview
|
||||
modal, whose <iframe> would otherwise trigger a download.
|
||||
"""
|
||||
raw = file_name.encode("ascii", "ignore").decode("ascii")
|
||||
# keep the fallback header-safe: no quotes, no control characters
|
||||
@@ -56,9 +61,37 @@ def _content_disposition(file_name: str) -> str:
|
||||
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
|
||||
if not stem or not any(ch.isalnum() for ch in stem):
|
||||
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
|
||||
# name): use a generic attachment name that keeps the extension
|
||||
fallback = f"attachment{ext}" if ext else "attachment"
|
||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
||||
# name): use a generic name that keeps the extension
|
||||
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
|
||||
"preview" if inline else "attachment"
|
||||
)
|
||||
kind = "inline" if inline else "attachment"
|
||||
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
||||
|
||||
|
||||
async def _require_file_access(
|
||||
request: Request,
|
||||
token: str | None,
|
||||
quote_id: uuid.UUID,
|
||||
kind: str,
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Authorise a file read by session header OR a signed ``?t=`` token.
|
||||
|
||||
These two GET routes sit outside the global Bearer dependency because a
|
||||
browser cannot attach an Authorization header to <iframe>/<img> loads.
|
||||
A caller is accepted when it presents either a valid Casdoor session
|
||||
(resolved by get_optional_user) or a signed token scoped to exactly
|
||||
this file, which expires within minutes.
|
||||
"""
|
||||
if request is not None and request.headers.get("authorization"):
|
||||
user = await get_optional_user(request, db)
|
||||
if user is not None and user.role is not None:
|
||||
return
|
||||
if token and verify_preview_token(token, quote_id, kind, attachment_id):
|
||||
return
|
||||
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
||||
@@ -92,7 +125,7 @@ async def _attachment_or_404(
|
||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/invoice", status_code=201)
|
||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
@@ -103,29 +136,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
||||
return {"data": stored.to_dict()}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/invoice")
|
||||
@router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
|
||||
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachments = await file_service.list_for(db, quote_id, "invoice")
|
||||
return {"data": [a.to_dict() for a in attachments]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview URL — a real, short-lived URL the browser can load in an
|
||||
# <iframe>/<img> (which cannot send an Authorization header). Using a real
|
||||
# URL instead of a blob: avoids download managers that hijack blob URLs and
|
||||
# leave 0-byte files behind.
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
|
||||
async def preview_url(
|
||||
quote_id: uuid.UUID,
|
||||
kind: str,
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_ctx: UserContext = Depends(get_current_user),
|
||||
):
|
||||
if kind not in ("invoice", "proforma"):
|
||||
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
|
||||
await _quote_or_404(db, quote_id)
|
||||
await _attachment_or_404(db, quote_id, attachment_id, kind)
|
||||
token = make_preview_token(quote_id, kind, attachment_id)
|
||||
# مسیر نسبی برگردانده میشود؛ فرانت با BASE_URL خودش کاملش میکند
|
||||
return {
|
||||
"data": {
|
||||
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
|
||||
"token": token,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/invoice/{attachment_id}")
|
||||
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
async def download_invoice(
|
||||
quote_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
disposition: str = "attachment",
|
||||
t: str | None = None,
|
||||
request: Request = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
|
||||
content = await file_service.read_bytes(attachment)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
||||
"Content-Disposition": _content_disposition(
|
||||
attachment.file_name, inline=disposition == "inline"
|
||||
),
|
||||
"X-File-Id": str(attachment.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
||||
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||
@@ -137,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
||||
# Proforma — no status restriction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/proforma", status_code=201)
|
||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
@@ -148,7 +219,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
||||
return {"data": stored.to_dict()}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/proforma")
|
||||
@router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
|
||||
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachments = await file_service.list_for(db, quote_id, "proforma")
|
||||
@@ -156,21 +227,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
|
||||
|
||||
@router.get("/{quote_id}/proforma/{attachment_id}")
|
||||
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
async def download_proforma(
|
||||
quote_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
disposition: str = "attachment",
|
||||
t: str | None = None,
|
||||
request: Request = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
|
||||
content = await file_service.read_bytes(attachment)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
||||
"Content-Disposition": _content_disposition(
|
||||
attachment.file_name, inline=disposition == "inline"
|
||||
),
|
||||
"X-File-Id": str(attachment.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
||||
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||
|
||||
@@ -274,12 +274,13 @@ async def update_warehouse(
|
||||
wh.name = payload.name
|
||||
if payload.description is not None:
|
||||
wh.description = payload.description
|
||||
# پروژهی انبار تغییرناپذیر است — انبار برای همیشه به پروژهی والدش تعلق
|
||||
# دارد و فقط با حذف و ساخت دوباره میتواند جابهجا شود.
|
||||
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
||||
if await db.get(Project, payload.project_id) is None:
|
||||
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
|
||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
||||
raise ForbiddenError("PM فقط میتواند انبار را در پروژه خودش جابهجا کند", field="project_id")
|
||||
wh.project_id = payload.project_id
|
||||
raise ValidationError(
|
||||
"پروژهی انبار قابل تغییر نیست — انبار برای همیشه به پروژهی والدش تعلق دارد",
|
||||
field="project_id",
|
||||
)
|
||||
await db.flush()
|
||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||
|
||||
@@ -313,7 +314,6 @@ async def delete_warehouse(
|
||||
@router.get("")
|
||||
async def list_inventory(
|
||||
project_id: uuid.UUID | None = None,
|
||||
category_id=None,
|
||||
warehouse_id=None,
|
||||
material_id=None,
|
||||
sort: str | None = None,
|
||||
@@ -331,10 +331,6 @@ async def list_inventory(
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == project_id
|
||||
)
|
||||
if category_id is not None:
|
||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
||||
Material.category_id == category_id
|
||||
)
|
||||
if warehouse_id is not None:
|
||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||
if material_id is not None:
|
||||
@@ -356,10 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
||||
unit_ids = {i.unit_id for i in items}
|
||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
categories = {}
|
||||
if materials:
|
||||
cat_ids = {m.category_id for m in materials.values()}
|
||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
brands = {}
|
||||
if brand_ids:
|
||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||
@@ -369,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
||||
return [
|
||||
inventory_to_dict(
|
||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
||||
None, brands.get(i.brand_id), units.get(i.unit_id),
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
@@ -531,58 +523,3 @@ async def transaction_types():
|
||||
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
||||
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
||||
)]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material selector — filtered by Category + Warehouse (transaction forms)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/materials/options")
|
||||
async def material_options(
|
||||
category_id: uuid.UUID,
|
||||
warehouse_id: uuid.UUID | None = None,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Materials for the transaction form.
|
||||
|
||||
Always filtered by category. When `warehouse_id` is given, only materials
|
||||
that already exist in that warehouse's inventory are returned (so the PM
|
||||
works with the actual stock of that warehouse); otherwise the full
|
||||
category list is returned (e.g. first purchase of a new material).
|
||||
"""
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
|
||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
||||
if warehouse_id is not None:
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
stmt = stmt.where(
|
||||
Material.id.in_(
|
||||
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(Material.name)
|
||||
materials = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
# Brands per material (from brand_materials catalog + brands actually used in stock)
|
||||
brand_rows = (await db.execute(
|
||||
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
||||
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
||||
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
|
||||
)).all()
|
||||
brands_by_material: dict[str, list[dict]] = {}
|
||||
for mat_id, b_id, b_name in brand_rows:
|
||||
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
|
||||
|
||||
return {"data": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"brands": brands_by_material.get(str(m.id), []),
|
||||
}
|
||||
for m in materials
|
||||
]}
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Supplier
|
||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
||||
# v2.7: materials no longer carry a category, so what blocks deletion is the
|
||||
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
|
||||
# material of that category is reached through those suppliers.
|
||||
linked = await db.scalar(
|
||||
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
|
||||
)
|
||||
if linked:
|
||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
||||
raise ConflictError(f"این دستهبندی برای {linked} تأمینکننده ثبت شده و قابل حذف نیست")
|
||||
await db.delete(category) # category_attributes cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(category_id), "deleted": True}}
|
||||
|
||||
@@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
|
||||
async def list_materials(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
brand_id=None,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
@@ -107,8 +106,6 @@ async def list_materials(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Material)
|
||||
if category_id:
|
||||
stmt = stmt.where(Material.category_id == category_id)
|
||||
if brand_id:
|
||||
# WHERE EXISTS brand_materials with that brand_id
|
||||
sub = select(1).where(
|
||||
|
||||
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
||||
from app.models.catalog import BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -201,18 +201,6 @@ async def pm_on_the_way(
|
||||
# unit / category lookups for display
|
||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
mat_ids = {d.Material.id for d in decisions}
|
||||
cats = {}
|
||||
if mat_ids:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Material.id, MaterialCategory)
|
||||
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
||||
.where(Material.id.in_(mat_ids))
|
||||
)
|
||||
).all()
|
||||
cats = {m_id: c for m_id, c in rows}
|
||||
|
||||
out = []
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
received = received_map.get(d.id)
|
||||
@@ -225,8 +213,7 @@ async def pm_on_the_way(
|
||||
"supplier": {"id": str(sup.id), "name": sup.name},
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"material": {"id": str(m.id), "name": m.name,
|
||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
||||
"material": {"id": str(m.id), "name": m.name},
|
||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||
"ordered_qty": str(li.quantity),
|
||||
|
||||
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material
|
||||
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
LineItem,
|
||||
LineItemProjects,
|
||||
Project,
|
||||
Quote,
|
||||
QuoteProjects,
|
||||
Supplier,
|
||||
Termsheet,
|
||||
)
|
||||
from app.schemas.procurement import (
|
||||
AcceptQuoteIn,
|
||||
ArriveItemsIn,
|
||||
@@ -36,15 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||
|
||||
# --- list filter helpers ---------------------------------------------------
|
||||
|
||||
def _quote_category_exists(category_id) -> exists:
|
||||
return exists(
|
||||
select(1).where(
|
||||
LineItem.quote_id == Quote.id,
|
||||
Material.id == LineItem.material_id,
|
||||
Material.category_id == category_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _quote_search(q: str) -> exists:
|
||||
like = f"%{q}%"
|
||||
@@ -72,7 +72,6 @@ async def get_quote_stats(
|
||||
project_id=None,
|
||||
quote_date_from: datetime | None = None,
|
||||
quote_date_to: datetime | None = None,
|
||||
category_id=None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {
|
||||
@@ -82,7 +81,6 @@ async def get_quote_stats(
|
||||
project_id=project_id,
|
||||
quote_date_from=quote_date_from,
|
||||
quote_date_to=quote_date_to,
|
||||
category_id=category_id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +96,6 @@ async def list_quotes(
|
||||
status: str | None = None,
|
||||
supplier_id=None,
|
||||
project_id=None,
|
||||
category_id=None,
|
||||
quote_date_from: datetime | None = None,
|
||||
quote_date_to: datetime | None = None,
|
||||
q: str | None = None,
|
||||
@@ -112,9 +109,23 @@ async def list_quotes(
|
||||
if supplier_id:
|
||||
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
||||
if project_id:
|
||||
stmt = stmt.where(Quote.project_id == project_id)
|
||||
if category_id:
|
||||
stmt = stmt.where(_quote_category_exists(category_id))
|
||||
# any relevant link: legacy mirror, quote-level junction, line-item
|
||||
# candidates, or an accepted (decision) target — NOT just the mirror
|
||||
stmt = stmt.where(
|
||||
Quote.id.in_(
|
||||
select(Quote.id)
|
||||
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||
.where(
|
||||
(Quote.project_id == project_id)
|
||||
| (QuoteProjects.project_id == project_id)
|
||||
| (LineItemProjects.project_id == project_id)
|
||||
| (ItemDecision.project_id == project_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
if quote_date_from is not None:
|
||||
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
||||
if quote_date_to is not None:
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
||||
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
||||
GET /materials/{id}/suppliers — price-comparison data source.
|
||||
GET /materials/{id}/suppliers — suppliers linked to a material.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
@@ -20,29 +22,51 @@ from app.utils.serialization import to_iso
|
||||
router = APIRouter(tags=["supplier-materials"])
|
||||
|
||||
|
||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
||||
def link_to_dict(
|
||||
link: SupplierMaterial,
|
||||
material: Material | None = None,
|
||||
category: MaterialCategory | None = None,
|
||||
supplier: Supplier | None = None) -> dict:
|
||||
supplier: Supplier | None = None,
|
||||
) -> dict:
|
||||
<<<<<<< Updated upstream
|
||||
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
|
||||
=======
|
||||
"""Serialize a SupplierMaterial ORM row to a plain dict."""
|
||||
>>>>>>> Stashed changes
|
||||
return {
|
||||
"id": str(link.id),
|
||||
"supplier_id": str(link.supplier_id),
|
||||
"material_id": str(link.material_id),
|
||||
"is_active": link.is_active,
|
||||
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
|
||||
"last_quoted_price": (
|
||||
float(link.last_quoted_price) if link.last_quoted_price is not None else None
|
||||
),
|
||||
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
||||
"created_at": to_iso(link.created_at),
|
||||
"material": {
|
||||
"material": (
|
||||
{
|
||||
"id": str(material.id),
|
||||
"name": material.name,
|
||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
||||
} if material else None,
|
||||
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
||||
"category": (
|
||||
{"id": str(category.id), "name": category.name} if category else None
|
||||
),
|
||||
}
|
||||
if material
|
||||
else None
|
||||
),
|
||||
"supplier": (
|
||||
{"id": str(supplier.id), "name": supplier.name} if supplier else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /suppliers/{supplier_id}/materials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/suppliers/{supplier_id}/materials")
|
||||
async def list_supplier_materials(
|
||||
supplier_id,
|
||||
supplier_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -56,49 +80,210 @@ async def list_supplier_materials(
|
||||
.order_by(SupplierMaterial.created_at)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
mat_ids = {l.material_id for l in items}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
cat_ids = {m.category_id for m in materials.values() if m.category_id}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
|
||||
mat_ids = [lnk.material_id for lnk in items]
|
||||
materials: dict = {}
|
||||
if mat_ids:
|
||||
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
|
||||
materials = {m.id: m for m in result.scalars()}
|
||||
return {
|
||||
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /suppliers/{supplier_id}/materials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||
async def create_supplier_material(
|
||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
||||
supplier_id: UUID,
|
||||
payload: SupplierMaterialCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
async with db.begin():
|
||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||
material = await db.get(Material, payload.material_id)
|
||||
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
|
||||
return {"data": link_to_dict(link, material, category)}
|
||||
return {"data": link_to_dict(link, material)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /suppliers/{supplier_id}/materials/{material_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_supplier_material(
|
||||
supplier_id: UUID,
|
||||
material_id: UUID,
|
||||
<<<<<<< Updated upstream
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
async with db.begin():
|
||||
await unlink_supplier_material(db, supplier_id, material_id)
|
||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||
return {
|
||||
"data": {
|
||||
"supplier_id": str(supplier_id),
|
||||
"material_id": str(material_id),
|
||||
"deleted": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/materials/{material_id}/suppliers")
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /materials/{material_id}/suppliers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/materials/{material_id}/suppliers",
|
||||
summary="List all suppliers linked to a material",
|
||||
)
|
||||
async def list_material_suppliers(
|
||||
material_id,
|
||||
material_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the suppliers linked to a material, straight from PostgreSQL.
|
||||
|
||||
The path parameter is typed as UUID so FastAPI validates and coerces it
|
||||
before the handler runs; a malformed id is rejected with 422, an unknown
|
||||
material with 404.
|
||||
|
||||
Atomic JOIN — a supplier is returned only through its supplier_materials
|
||||
link, so the response can never contain a supplier that is not actually
|
||||
linked to this material:
|
||||
|
||||
select(Supplier)
|
||||
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
|
||||
Response envelope (frontend MaterialSupplier binds these fields directly):
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "<supplier_uuid>",
|
||||
"name": "فولاد ناب آریا",
|
||||
"phone": "021-..." | null,
|
||||
"contact_person": "علی محمدی" | null,
|
||||
"location": "تهران" | null
|
||||
}
|
||||
]
|
||||
=======
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
async with db.begin():
|
||||
await unlink_supplier_material(db, supplier_id, material_id)
|
||||
return {
|
||||
"data": {
|
||||
"supplier_id": str(supplier_id),
|
||||
"material_id": str(material_id),
|
||||
"deleted": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /materials/{material_id}/suppliers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/materials/{material_id}/suppliers",
|
||||
summary="List all suppliers linked to a material",
|
||||
)
|
||||
async def list_material_suppliers(
|
||||
material_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return a paginated list of suppliers for a given material.
|
||||
|
||||
Uses explicit column-level SELECT with named labels so SQLAlchemy returns
|
||||
plain Row tuples — no ORM identity-map involvement — eliminating the
|
||||
string-vs-UUID comparison bug and the stale-entity collision that occurred
|
||||
when selecting two mapped entities in the same session.
|
||||
|
||||
Response shape (matched by the frontend RawMaterialSupplier interface):
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "<supplier_uuid>",
|
||||
"name": "فولاد ناب آریا",
|
||||
"phone": "021-..." | null,
|
||||
"contact_person": "علی محمدی" | null,
|
||||
"location": "تهران" | null
|
||||
}
|
||||
],
|
||||
"meta": {"total": 1, "page": 1, "page_size": 20}
|
||||
>>>>>>> Stashed changes
|
||||
}
|
||||
"""
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
stmt = (
|
||||
select(SupplierMaterial)
|
||||
select(Supplier)
|
||||
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
.order_by(Supplier.name)
|
||||
)
|
||||
suppliers = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": str(supplier.id),
|
||||
"name": supplier.name,
|
||||
"phone": supplier.phone,
|
||||
"contact_person": supplier.contact_person,
|
||||
"location": supplier.location,
|
||||
}
|
||||
for supplier in suppliers
|
||||
]
|
||||
=======
|
||||
total: int = (
|
||||
await db.scalar(
|
||||
select(func.count(SupplierMaterial.id)).where(
|
||||
SupplierMaterial.material_id == material_id
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
|
||||
if total == 0:
|
||||
return {"data": [], "meta": {"total": 0, "page": page, "page_size": page_size}}
|
||||
|
||||
# Explicit column-level SELECT — returns plain Row objects keyed by label,
|
||||
# never ORM entities, so no identity-map collision is possible.
|
||||
stmt = (
|
||||
select(
|
||||
Supplier.id.label("sup_id"),
|
||||
Supplier.name.label("sup_name"),
|
||||
Supplier.phone.label("sup_phone"),
|
||||
Supplier.contact_person.label("sup_contact_person"),
|
||||
Supplier.location.label("sup_location"),
|
||||
)
|
||||
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
sup_ids = {l.supplier_id for l in items}
|
||||
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
data = [
|
||||
{
|
||||
"id": str(row.sup_id),
|
||||
"name": row.sup_name,
|
||||
"phone": row.sup_phone,
|
||||
"contact_person": row.sup_contact_person,
|
||||
"location": row.sup_location,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"meta": {"total": total, "page": page, "page_size": page_size},
|
||||
>>>>>>> Stashed changes
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
|
||||
|
||||
Model: {category_id, brand?, from_unit, to_unit, factor}
|
||||
1 from_unit = factor × to_unit
|
||||
brand empty = all brands of the category; brand set = that brand only.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory, UnitConversion
|
||||
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
|
||||
from app.services.expand_service import category_brief
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
|
||||
|
||||
|
||||
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(uc.id),
|
||||
"category_id": str(uc.category_id),
|
||||
"brand": uc.brand,
|
||||
"from_unit": uc.from_unit,
|
||||
"to_unit": uc.to_unit,
|
||||
"factor": float(uc.factor),
|
||||
"updated_at": to_iso(uc.updated_at),
|
||||
}
|
||||
if "category" in expand and hasattr(uc, "_category"):
|
||||
d["category"] = category_brief(uc._category)
|
||||
return d
|
||||
|
||||
|
||||
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
|
||||
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
|
||||
where = (
|
||||
UnitConversion.category_id == category_id,
|
||||
UnitConversion.from_unit == from_unit,
|
||||
UnitConversion.to_unit == to_unit,
|
||||
)
|
||||
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده میشود
|
||||
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
|
||||
if uc_or_none is not None:
|
||||
where = where + (UnitConversion.id != uc_or_none.id,)
|
||||
return where
|
||||
|
||||
|
||||
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
|
||||
duplicate = await db.scalar(
|
||||
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این تبدیل قبلاً ثبت شده است")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_unit_conversions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
brand: str | None = None,
|
||||
from_unit: str | None = None,
|
||||
to_unit: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(UnitConversion)
|
||||
if category_id:
|
||||
stmt = stmt.where(UnitConversion.category_id == category_id)
|
||||
if brand:
|
||||
stmt = stmt.where(UnitConversion.brand == brand)
|
||||
if from_unit:
|
||||
stmt = stmt.where(UnitConversion.from_unit == from_unit)
|
||||
if to_unit:
|
||||
stmt = stmt.where(UnitConversion.to_unit == to_unit)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "category" in tokens:
|
||||
cat_ids = {uc.category_id for uc in items}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
for uc in items:
|
||||
uc._category = cats.get(uc.category_id)
|
||||
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, payload.category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
|
||||
uc = UnitConversion(
|
||||
category_id=payload.category_id,
|
||||
brand=payload.brand,
|
||||
from_unit=payload.from_unit,
|
||||
to_unit=payload.to_unit,
|
||||
factor=payload.factor,
|
||||
)
|
||||
db.add(uc)
|
||||
await db.flush()
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.get("/{uc_id}")
|
||||
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.patch("/{uc_id}")
|
||||
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
new_brand = payload.brand if payload.brand is not None else uc.brand
|
||||
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
|
||||
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
|
||||
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
|
||||
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
|
||||
if payload.brand is not None:
|
||||
uc.brand = payload.brand
|
||||
if payload.from_unit is not None:
|
||||
uc.from_unit = payload.from_unit
|
||||
if payload.to_unit is not None:
|
||||
uc.to_unit = payload.to_unit
|
||||
if payload.factor is not None:
|
||||
uc.factor = payload.factor
|
||||
await db.flush()
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.delete("/{uc_id}")
|
||||
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
await db.delete(uc)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
|
||||
|
||||
|
||||
class MaterialCreate(APIModel):
|
||||
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
|
||||
name: str = Field(min_length=1)
|
||||
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
||||
canonical_unit_id: int
|
||||
|
||||
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
|
||||
|
||||
|
||||
class WarehouseUpdate(APIModel):
|
||||
"""Rename/re-describe a warehouse.
|
||||
|
||||
`project_id` is accepted only to be rejected: a warehouse belongs
|
||||
permanently to its parent project and cannot be reassigned (the router
|
||||
answers 400 unless the value equals the current project).
|
||||
"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
project_id: UUID | None = None
|
||||
|
||||
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
|
||||
|
||||
|
||||
class SupplierMaterialCreate(APIModel):
|
||||
"""Link a global material to a supplier (derives materials.category_id)."""
|
||||
"""Link a global material to a supplier."""
|
||||
|
||||
material_id: UUID
|
||||
|
||||
|
||||
@@ -37,13 +37,14 @@ async def track(event: str, properties: dict[str, Any] | None = None, profile_id
|
||||
"""Record a named event; swallows every error."""
|
||||
if not _configured():
|
||||
return
|
||||
body = {
|
||||
"type": "track",
|
||||
"event": event,
|
||||
"properties": properties or {},
|
||||
}
|
||||
# OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
|
||||
# sends) — a flat body is rejected with 400, which this module swallows by design.
|
||||
# The payload field is "name" (ITrackPayload in packages/validation) — "event" is
|
||||
# rejected with a 400 that this module swallows.
|
||||
inner: dict[str, Any] = {"name": event, "properties": properties or {}}
|
||||
if profile_id:
|
||||
body["profileId"] = profile_id
|
||||
inner["profileId"] = profile_id
|
||||
body = {"type": "track", "payload": inner}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
await client.post(
|
||||
@@ -66,7 +67,7 @@ async def identify(profile_id: str, properties: dict[str, Any] | None = None) ->
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
await client.post(
|
||||
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
|
||||
json={"type": "identify", "profileId": profile_id, "properties": properties or {}},
|
||||
json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
|
||||
headers={
|
||||
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
|
||||
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
|
||||
|
||||
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
|
||||
material_ids = [m.id for m in materials]
|
||||
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
||||
|
||||
if "category" in expand:
|
||||
cat_ids = {m.category_id for m in materials}
|
||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
||||
maps["categories"] = {c.id: c for c in rows}
|
||||
|
||||
unit_ids: set[int] = set()
|
||||
if "canonical_unit" in expand:
|
||||
unit_ids |= {m.canonical_unit_id for m in materials}
|
||||
@@ -257,10 +252,12 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
||||
"created_at": to_iso(m.created_at),
|
||||
"updated_at": to_iso(m.updated_at),
|
||||
}
|
||||
# Category is a supplier attribute now, not a material one — the key stays
|
||||
# in the payload (null) so the contract shape is unchanged.
|
||||
if "category" in expand:
|
||||
d["category"] = category_brief(maps["categories"].get(m.category_id))
|
||||
d["category"] = None
|
||||
else:
|
||||
d["category_id"] = str(m.category_id)
|
||||
d["category_id"] = None
|
||||
if "canonical_unit" in expand:
|
||||
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
||||
else:
|
||||
@@ -326,9 +323,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
||||
material_ids = {li.material_id for li in line_items}
|
||||
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
||||
data.materials = {m.id: m for m in materials}
|
||||
cat_ids = {m.category_id for m in materials}
|
||||
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
||||
data.categories = {c.id: c for c in cats}
|
||||
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
||||
brand_ids: set[uuid.UUID] = set()
|
||||
for bm in bms:
|
||||
@@ -450,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
||||
d["material"] = {
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"category": category_brief(data.categories.get(m.category_id)),
|
||||
"category": None,
|
||||
"brands": [
|
||||
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
||||
for bm in data.material_brands.get(m.id, [])
|
||||
|
||||
@@ -12,7 +12,6 @@ from app.models.catalog import (
|
||||
BrandMaterial,
|
||||
Material,
|
||||
MaterialAttributeValue,
|
||||
MaterialCategory,
|
||||
Unit,
|
||||
)
|
||||
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
||||
@@ -110,9 +109,7 @@ async def validate_attributes(
|
||||
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
||||
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
||||
|
||||
v2.5/2.6: category_id is OPTIONAL — a global material may be created
|
||||
without a category (it gets derived automatically from the first linked
|
||||
supplier). name is GLOBALLY unique.
|
||||
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
|
||||
"""
|
||||
async with db.begin():
|
||||
duplicate = await db.scalar(
|
||||
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
|
||||
if duplicate is not None:
|
||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||
|
||||
if payload.category_id is not None:
|
||||
if await db.get(MaterialCategory, payload.category_id) is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
if not await db.get(Unit, payload.canonical_unit_id):
|
||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||
|
||||
material = Material(
|
||||
category_id=payload.category_id,
|
||||
name=payload.name,
|
||||
topic=payload.topic,
|
||||
canonical_unit_id=payload.canonical_unit_id,
|
||||
|
||||
@@ -873,7 +873,6 @@ async def quote_stats(
|
||||
project_id: UUID | None = None,
|
||||
quote_date_from=None,
|
||||
quote_date_to=None,
|
||||
category_id: UUID | None = None,
|
||||
) -> dict:
|
||||
conditions = []
|
||||
if supplier_id is not None:
|
||||
@@ -884,8 +883,6 @@ async def quote_stats(
|
||||
conditions.append(Quote.quote_date >= quote_date_from)
|
||||
if quote_date_to is not None:
|
||||
conditions.append(Quote.quote_date <= quote_date_to)
|
||||
if category_id is not None:
|
||||
conditions.append(_quote_has_category(category_id))
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
@@ -917,38 +914,46 @@ async def quote_stats(
|
||||
}
|
||||
|
||||
|
||||
def _quote_has_category(category_id: UUID):
|
||||
"""EXISTS: quote has a line item whose material belongs to category_id."""
|
||||
from app.models.catalog import Material
|
||||
|
||||
sub = (
|
||||
select(1)
|
||||
.where(
|
||||
LineItem.quote_id == Quote.id,
|
||||
Material.id == LineItem.material_id,
|
||||
Material.category_id == category_id,
|
||||
)
|
||||
)
|
||||
return func.exists(sub)
|
||||
|
||||
|
||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
"""Counts + total value per quote status for one project.
|
||||
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||
|
||||
total_value: SUM(quantity × unit_price) over line items using the chosen
|
||||
termsheet link first, else the first link. Rejected line items are
|
||||
excluded from value sums (contract P1-14).
|
||||
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
|
||||
least one non-rejected line item's decision targets this project
|
||||
(item_decisions.project_id). A quote merely linked to the project as a
|
||||
candidate (quote-level link, line-item candidates, or accepted for a
|
||||
DIFFERENT project) stays in «offered» — this project never accepted it.
|
||||
|
||||
total_value: SUM(quantity × unit_price) over the bucket's line items,
|
||||
using the chosen termsheet link first, else the first link. Rejected
|
||||
line items are excluded from value sums (contract P1-14).
|
||||
"""
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
# the route passes the raw path string — normalize for the Python-side
|
||||
# UUID comparisons below (decision.project_id == project_id etc.)
|
||||
project_id = UUID(str(project_id))
|
||||
|
||||
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
|
||||
if not quotes:
|
||||
# --- every quote RELEVANT to this project (any kind of link) ---
|
||||
quote_id_rows = await db.execute(
|
||||
select(Quote.id)
|
||||
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||
.where(
|
||||
(Quote.project_id == project_id)
|
||||
| (QuoteProjects.project_id == project_id)
|
||||
| (LineItemProjects.project_id == project_id)
|
||||
| (ItemDecision.project_id == project_id)
|
||||
)
|
||||
)
|
||||
quote_ids = [row[0] for row in quote_id_rows.all()]
|
||||
if not quote_ids:
|
||||
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
||||
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
||||
|
||||
quote_ids = [q.id for q in quotes]
|
||||
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
|
||||
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
||||
li_ids = [li.id for li in line_items]
|
||||
|
||||
@@ -956,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
d.line_item_id: d
|
||||
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||
} if li_ids else {}
|
||||
# line items explicitly linked to this project as candidates
|
||||
candidate_li_ids = set(
|
||||
(
|
||||
await db.execute(
|
||||
select(LineItemProjects.line_item_id).where(
|
||||
LineItemProjects.line_item_id.in_(li_ids),
|
||||
LineItemProjects.project_id == project_id,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
) if li_ids else set()
|
||||
|
||||
item_termsheets = (
|
||||
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||
) if li_ids else []
|
||||
@@ -964,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
for it in item_termsheets:
|
||||
its_by_line.setdefault(it.line_item_id, []).append(it)
|
||||
|
||||
def _items_of(quote: Quote) -> list[LineItem]:
|
||||
return [li for li in line_items if li.quote_id == quote.id]
|
||||
|
||||
def _accepted_for_project(quote: Quote) -> bool:
|
||||
"""≥1 non-rejected line item whose decision targets THIS project."""
|
||||
return any(
|
||||
(decision := decisions.get(li.id)) is not None
|
||||
and decision.status == "chosen"
|
||||
and decision.project_id == project_id
|
||||
for li in _items_of(quote)
|
||||
)
|
||||
|
||||
def _has_any_target(quote: Quote) -> bool:
|
||||
"""Whether any decision of this quote carries a target project at all
|
||||
(legacy quotes accepted before the decision-project era have none —
|
||||
they keep the old global-status classification)."""
|
||||
return any(
|
||||
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
|
||||
for li in _items_of(quote)
|
||||
)
|
||||
|
||||
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
||||
for q in quotes:
|
||||
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
|
||||
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
|
||||
else:
|
||||
per_status[q.status]["count"] += 1
|
||||
|
||||
for li in line_items:
|
||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||
decision = decisions.get(li.id)
|
||||
|
||||
# --- which status bucket does this line item's value belong to? ---
|
||||
accepted_here = (
|
||||
decision is not None
|
||||
and decision.status == "chosen"
|
||||
and decision.project_id == project_id
|
||||
)
|
||||
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
|
||||
if accepted_here:
|
||||
bucket = quote.status
|
||||
elif li.id in candidate_li_ids:
|
||||
# the quote is still just a proposal for this project — the
|
||||
# item's candidate value stays in the offered bucket
|
||||
bucket = "offered"
|
||||
else:
|
||||
continue # this item targets another project entirely
|
||||
else:
|
||||
bucket = quote.status
|
||||
|
||||
if decision is not None and decision.status == "rejected":
|
||||
continue # rejected items excluded from value sums
|
||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||
@@ -979,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
price_it = links[0] if links else None
|
||||
if price_it is None:
|
||||
continue
|
||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||
|
||||
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
||||
for status, values in per_status.items():
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
|
||||
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
|
||||
|
||||
Linking a material to a supplier derives materials.category_id from the
|
||||
supplier's category when the material has none (first-link-wins). The same
|
||||
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
|
||||
this service applies it in the application layer so the ORM state is
|
||||
consistent inside the same transaction.
|
||||
Suppliers can supply any global material across categories.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
||||
async def link_supplier_material(
|
||||
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
||||
) -> SupplierMaterial:
|
||||
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
|
||||
"""Upsert a supplier<->material link (idempotent).
|
||||
|
||||
Caller is responsible for the surrounding transaction.
|
||||
"""
|
||||
@@ -42,15 +38,11 @@ async def link_supplier_material(
|
||||
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
||||
db.add(link)
|
||||
await db.flush()
|
||||
|
||||
# First-link-wins: only when the material has no category yet.
|
||||
if material.category_id is None:
|
||||
material.category_id = supplier.category_id
|
||||
return link
|
||||
|
||||
|
||||
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
||||
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
|
||||
"""Remove the link."""
|
||||
link = await db.scalar(
|
||||
select(SupplierMaterial).where(
|
||||
SupplierMaterial.supplier_id == supplier_id,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Ephemeral, URL-safe file-preview tokens.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
File downloads require an ``Authorization: Bearer`` header, but a browser
|
||||
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
|
||||
src>`` — which is what the preview modal uses. The previous workaround
|
||||
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
|
||||
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
|
||||
outside the originating document, and hand the browser a 0-byte file under
|
||||
a bare UUID name — so the preview broke and a junk download appeared.
|
||||
|
||||
Instead we mint a short-lived HMAC token scoped to a single attachment and
|
||||
put it in the query string, giving the iframe a *real* URL that also
|
||||
carries its own authorization. The token is not a session credential: it
|
||||
authorises exactly one (quote_id, kind, attachment_id) triple and expires
|
||||
in minutes.
|
||||
|
||||
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# پیشنمایش کوتاهعمر است — فقط برای باز شدن مودال، نه اشتراکگذاری لینک
|
||||
DEFAULT_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
|
||||
base = (
|
||||
settings.CASDOOR_CLIENT_SECRET
|
||||
or settings.MINIO_SECRET_KEY
|
||||
or "procurement-preview-fallback"
|
||||
)
|
||||
return base.encode("utf-8")
|
||||
|
||||
|
||||
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
|
||||
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
|
||||
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def make_preview_token(
|
||||
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
|
||||
) -> str:
|
||||
expiry = int(time.time()) + ttl
|
||||
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
|
||||
|
||||
|
||||
def verify_preview_token(
|
||||
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""True when the token is well-formed, unexpired and matches this file."""
|
||||
if not token or "." not in token:
|
||||
return False
|
||||
expiry_raw, signature = token.split(".", 1)
|
||||
try:
|
||||
expiry = int(expiry_raw)
|
||||
except ValueError:
|
||||
return False
|
||||
if expiry < int(time.time()):
|
||||
return False
|
||||
expected = _sign(quote_id, kind, attachment_id, expiry)
|
||||
return hmac.compare_digest(expected, signature)
|
||||
@@ -209,7 +209,7 @@ async def run() -> None:
|
||||
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
|
||||
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
|
||||
"material_attribute_values, materials, brands, warehouses, projects, users, "
|
||||
"category_reference_units, unit_conversions, standard_conversions "
|
||||
"standard_conversions "
|
||||
"RESTART IDENTITY CASCADE"
|
||||
))
|
||||
# Only the 8 canonical topic categories may exist — any leftover slug
|
||||
@@ -261,9 +261,7 @@ async def run() -> None:
|
||||
material_by_name: dict[str, Material] = {}
|
||||
brand_material_by_material: dict[str, object] = {}
|
||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||
cat = await get_category(db, cat_slug)
|
||||
material = Material(
|
||||
category_id=cat.id,
|
||||
name=name,
|
||||
canonical_unit_id=unit_ids[unit_code],
|
||||
status="active",
|
||||
@@ -289,8 +287,10 @@ async def run() -> None:
|
||||
await db.flush()
|
||||
suppliers.append(sup)
|
||||
# last-quoted prices: link each supplier to 2 materials of its category
|
||||
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
|
||||
for sup in suppliers:
|
||||
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
|
||||
target_slug = sup_cat_slug.get(sup.name)
|
||||
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
|
||||
for m in cat_materials[:2]:
|
||||
db.add(SupplierMaterial(
|
||||
supplier_id=sup.id, material_id=m.id, is_active=True,
|
||||
|
||||
@@ -289,6 +289,11 @@ services:
|
||||
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
|
||||
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
|
||||
container_name: procurement-frontend
|
||||
# از entrypoint پیشفرض image nginx رد میشویم: آن اسکریپت `apk manifest`
|
||||
# اجرا میکند و اگر دسترسی به مخزن بستهها نباشد بینهایت معلق میماند
|
||||
# (سرویس بالا نمیآید و traefik خطای 502 میدهد). کانفیگ ما مستقل است و
|
||||
# نیازی به آن اسکریپتها ندارد.
|
||||
entrypoint: ["nginx", "-g", "daemon off;"]
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
|
||||
@@ -23,9 +23,13 @@ ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
||||
# Empty values disable the feature (analytics.ts is a no-op then).
|
||||
ARG VITE_OPENPANEL_URL=""
|
||||
ARG VITE_OPENPANEL_CLIENT_ID=""
|
||||
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
|
||||
# so it needs its own arg — defined here or the value never reaches the bundle.
|
||||
ARG VITE_OPENPANEL_SCRIPT_URL=""
|
||||
ARG VITE_SENTRY_DSN=""
|
||||
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
|
||||
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
|
||||
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
|
||||
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
||||
|
||||
# Source code + build
|
||||
|
||||
@@ -29,6 +29,26 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# MIME types: the pdf.js worker ships as .mjs, and a browser REFUSES to
|
||||
# start a module worker served as application/octet-stream (nginx's
|
||||
# default for unknown extensions) — which crashes the PDF preview page.
|
||||
# Declared explicitly rather than relying on the system mime.types.
|
||||
types {
|
||||
text/javascript mjs js;
|
||||
text/css css;
|
||||
text/html html;
|
||||
image/svg+xml svg;
|
||||
image/png png;
|
||||
image/jpeg jpg jpeg;
|
||||
image/webp webp;
|
||||
image/x-icon ico;
|
||||
font/woff2 woff2;
|
||||
font/woff woff;
|
||||
application/json json;
|
||||
application/pdf pdf;
|
||||
application/wasm wasm;
|
||||
}
|
||||
|
||||
# SPA fallback — all client-side routes go to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Generated
+263
@@ -23,6 +23,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.30.0",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
@@ -174,6 +175,256 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@openpanel/sdk": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
|
||||
@@ -2299,6 +2550,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.10.38",
|
||||
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
|
||||
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.65"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/react": "^10.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "3.10",
|
||||
@@ -20,11 +19,13 @@
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@sentry/react": "^10.0.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.30.0",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface ComboboxProps {
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||
openOnFocus?: boolean;
|
||||
}
|
||||
|
||||
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
||||
@@ -49,6 +52,7 @@ export function Combobox({
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
openOnFocus = true,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -132,9 +136,13 @@ export function Combobox({
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setHighlight(-1);
|
||||
if (!open) setOpen(true);
|
||||
onInputChange?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onFocus={() => {
|
||||
if (openOnFocus) setOpen(true);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
|
||||
import type { Attachment } from "../lib/types";
|
||||
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
|
||||
|
||||
export interface FileListProps {
|
||||
files: Attachment[];
|
||||
@@ -11,12 +13,25 @@ export interface FileListProps {
|
||||
onDelete?: (a: Attachment) => void;
|
||||
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
||||
onDownload?: (a: Attachment) => void | Promise<void>;
|
||||
// آدرس blob احراز هویتشده برای پیشنمایش. مودال پیشنمایش با objectURL
|
||||
// محلی کار میکند چون <img>/<iframe> نمیتوانند توکن بفرستند.
|
||||
loadPreview?: (a: Attachment) => Promise<string>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
||||
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
|
||||
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
||||
export function FileList({
|
||||
files,
|
||||
onChange,
|
||||
upload,
|
||||
onDelete,
|
||||
onDownload,
|
||||
loadPreview,
|
||||
label = "افزودن فاکتور",
|
||||
}: FileListProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||
|
||||
const remove = (i: number) => {
|
||||
const removed = files[i];
|
||||
@@ -39,31 +54,108 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
||||
if (added.length) onChange([...files, ...added]);
|
||||
};
|
||||
|
||||
// پیشنمایش: یک URL امضاشدهی کوتاهعمر میگیریم (نه blob) تا
|
||||
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
|
||||
const handleOpenPreview = async (inv: Attachment) => {
|
||||
if (!loadPreview) return;
|
||||
if (!inv.id) {
|
||||
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
|
||||
return;
|
||||
}
|
||||
setLoadingPreview(inv.id);
|
||||
try {
|
||||
const url = await loadPreview(inv);
|
||||
setPreviewItem({ url, name: inv.name });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "بارگذاری پیشنمایش ناموفق بود");
|
||||
} finally {
|
||||
setLoadingPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const closePreview = () => setPreviewItem(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
||||
{files.map((inv, i) => (
|
||||
<div key={i} className="inv-row">
|
||||
<span className="inv-name">📎 {inv.name}</span>
|
||||
<div className="space-y-2">
|
||||
{!files.length && (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
|
||||
هنوز فاکتور یا سندی بارگذاری نشده است.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{files.map((inv, i) => {
|
||||
const canPreview = !!loadPreview && !!inv.id;
|
||||
const isLoading = !!inv.id && loadingPreview === inv.id;
|
||||
|
||||
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 ${canPreview ? "cursor-pointer group" : ""}`}
|
||||
onClick={() => canPreview && void handleOpenPreview(inv)}
|
||||
title={canPreview ? "مشاهده پیشنمایش" : inv.name}
|
||||
>
|
||||
{isImageFile(inv.name) ? (
|
||||
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||||
) : isPdfFile(inv.name) ? (
|
||||
<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">
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenPreview(inv)}
|
||||
disabled={isLoading}
|
||||
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 disabled:opacity-50"
|
||||
title="مشاهده فاکتور"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="inv-open"
|
||||
title="دانلود"
|
||||
onClick={() => {
|
||||
Promise.resolve(onDownload(inv)).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
||||
);
|
||||
}}
|
||||
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" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
||||
×
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
title="حذف فایل"
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -73,10 +165,20 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
||||
+ {label}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span>+</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* مودال پیشنمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
|
||||
{previewItem && (
|
||||
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import { PdfCanvas } from "./PdfCanvas";
|
||||
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
||||
|
||||
export const isImageFile = (name: string = "") => {
|
||||
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
||||
};
|
||||
|
||||
export const isPdfFile = (name: string = "") => {
|
||||
return /\.pdf$/i.test(name.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* آیا مرورگر میتواند PDF را درونخطی نشان دهد؟
|
||||
*
|
||||
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
|
||||
* شبیهسازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
|
||||
* منتهی میشود، پس آنجا pdf.js استفاده میکنیم.
|
||||
*/
|
||||
export function canRenderPdfNatively(): boolean {
|
||||
if (typeof navigator === "undefined") return true;
|
||||
const ua = navigator.userAgent || "";
|
||||
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
||||
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیقترین معیار است
|
||||
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
|
||||
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
|
||||
return !mobile;
|
||||
}
|
||||
|
||||
export interface FilePreviewModalProps {
|
||||
// آدرس امضاشدهی کوتاهعمر — چون <img>/<iframe> نمیتوانند هدر توکن بفرستند
|
||||
url: string;
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* مودال پیشنمایش فایل — مشترک بین لیست پیوستها (FileList) و کارت پیشنهاد
|
||||
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم میشود (بدون
|
||||
* وابستگی به پلاگین PDF مرورگر، که در شبیهسازی موبایل وجود ندارد)؛ سایر
|
||||
* فرمتها فقط دکمهی دانلود دارند.
|
||||
*/
|
||||
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
|
||||
return (
|
||||
<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={onClose}
|
||||
>
|
||||
<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(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">{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<a
|
||||
href={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={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
|
||||
title="بستن"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بدنه نمایش فایل */}
|
||||
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
|
||||
{isImageFile(name) ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={name}
|
||||
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
||||
/>
|
||||
) : isPdfFile(name) ? (
|
||||
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را میدهد؛ در
|
||||
// شبیهسازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
|
||||
// canvas رسم میکند. انتخاب در زمان اجرا با تشخیص قابلیت.
|
||||
canRenderPdfNatively() ? (
|
||||
<iframe
|
||||
src={url}
|
||||
title={name}
|
||||
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
||||
/>
|
||||
) : (
|
||||
<PdfCanvas url={url} className="w-full" />
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* هوک مدیریت پیشنمایش: بارگذاری blob احراز هویتشده + آزادسازی objectURL.
|
||||
* خروجی برای رندر <FilePreviewModal> آماده است.
|
||||
*/
|
||||
/**
|
||||
* هوک مدیریت پیشنمایش: گرفتن URL امضاشده و نگهداشتن آن برای مودال.
|
||||
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
|
||||
* نیازی به آزادسازی objectURL نیست.
|
||||
*/
|
||||
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
|
||||
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
|
||||
const open = async (id: string, name: string) => {
|
||||
if (!id) return;
|
||||
setLoadingId(id);
|
||||
try {
|
||||
const url = await load(id, name);
|
||||
setItem({ url, name });
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => setItem(null);
|
||||
|
||||
return { item, loadingId, open, close };
|
||||
}
|
||||
@@ -12,15 +12,19 @@ export interface MilestoneRowProps {
|
||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||
return (
|
||||
<div className="milestone-row">
|
||||
<div className="flex items-start gap-2 w-full my-2">
|
||||
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||
<div className="w-28 shrink-0">
|
||||
<JalaliDatePicker
|
||||
value={milestone.when_iso}
|
||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||
placeholder="تاریخ"
|
||||
className="w-36 shrink-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="ms-amount-wrap">
|
||||
</div>
|
||||
|
||||
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<NumberInput
|
||||
value={milestone.amount}
|
||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||
@@ -29,8 +33,15 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف */}
|
||||
{!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>
|
||||
)}
|
||||
|
||||
@@ -7,9 +7,11 @@ export interface NumberInputProps {
|
||||
onChange: (n: number | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیشفرض: راستچین)
|
||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||
inputMode?: "numeric" | "decimal" | undefined;
|
||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||
maxDecimals?: number; // حداکثر رقم اعشار
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -19,6 +21,8 @@ export function NumberInput({
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
inputClassName,
|
||||
align = "right",
|
||||
showWords = false,
|
||||
inputMode = "numeric",
|
||||
maxDecimals,
|
||||
@@ -36,8 +40,6 @@ export function NumberInput({
|
||||
|
||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||
|
||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
||||
useEffect(() => {
|
||||
const cur = parseNum(text);
|
||||
if (value != null && value !== cur) {
|
||||
@@ -60,19 +62,28 @@ export function NumberInput({
|
||||
|
||||
const n = parseNum(text);
|
||||
|
||||
const alignClass =
|
||||
align === "center"
|
||||
? "!text-center text-center"
|
||||
: align === "left"
|
||||
? "!text-left text-left"
|
||||
: "!text-right text-right";
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className={cn("min-w-0 w-full", className)}>
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={cn(
|
||||
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
alignClass,
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
||||
const parsed = parseNum(raw);
|
||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||
if (parsed != null && !trailingSep) {
|
||||
@@ -85,16 +96,11 @@ export function NumberInput({
|
||||
}}
|
||||
onBlur={() => commit(text)}
|
||||
/>
|
||||
{showWords && (
|
||||
<span className={cn("num-pill", n == null && "empty")}>
|
||||
{n != null ? (
|
||||
<>
|
||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
{showWords && n != null && (
|
||||
<div className="mt-1 block text-right text-[11px] text-muted">
|
||||
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
|
||||
<span className="text-muted">({toWords(n)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* نمایشگر PDF با pdf.js (canvas) بهجای <iframe>.
|
||||
*
|
||||
* چرا: کروم در حالت شبیهسازی موبایل (F12) پلاگین PDF درونخطی ندارد، پس
|
||||
* <iframe src="...pdf"> را بهجای نمایش، دانلود میکند — که دانلودمنیجرها
|
||||
* شکارش میکنند و پیشنمایش خالی میماند. با رسم روی canvas هیچ ناوبریای
|
||||
* به آدرس فایل انجام نمیشود، پس نه به پلاگین مرورگر وابستهایم و نه چیزی
|
||||
* برای دانلودمنیجر باقی میماند.
|
||||
*/
|
||||
interface PdfCanvasProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PdfCanvas({ url, className }: PdfCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let doc: { destroy: () => void } | null = null;
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// بارگذاری تنبل: pdf.js فقط وقتی پیشنمایش PDF باز میشود دانلود میشود
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
// فقط workerSrc ست میشود — ستکردن همزمان workerPort باعث تعارض
|
||||
// میشد و بارگذاری سند در حلقه گیر میکرد («در حال بارگذاری…»).
|
||||
// فایل worker با پسوند .mjs سرو میشود و nginx آن را
|
||||
// text/javascript اعلام میکند (وگرنه مرورگر worker را رد میکند).
|
||||
const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
// بایتها را خودمان با fetch میگیریم و به pdf.js بهصورت داده
|
||||
// میدهیم، بهجای اینکه pdf.js خودش آدرس را بگیرد. دلیل: دانلودمنیجر
|
||||
// (IDM) درخواست pdf.js به آدرس PDF را میرباید و با 204 برمیگرداند
|
||||
// («Unexpected server response (204)»). با دادهی درونحافظه هیچ
|
||||
// درخواست قابلربودنی برای دانلودمنیجر باقی نمیماند.
|
||||
const res = await fetch(url, { credentials: "omit" });
|
||||
if (!res.ok) throw new Error(`دریافت فایل ناموفق بود (${res.status})`);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
|
||||
const task = pdfjs.getDocument({ data: bytes });
|
||||
// اگر worker/شبکه در دسترس نباشد، بهجای چرخ «در حال بارگذاری…»
|
||||
// بیپایان، خطای قابلفهم نشان بده.
|
||||
const loaded = await Promise.race([
|
||||
task.promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("زمان بارگذاری PDF به پایان رسید")), 20000),
|
||||
),
|
||||
]);
|
||||
doc = loaded as unknown as { destroy: () => void };
|
||||
if (cancelled) return;
|
||||
|
||||
setPageCount(loaded.numPages);
|
||||
|
||||
// فعلاً صفحهی اول — برای پیشنمایشِ سریع کافی است
|
||||
const page = await loaded.getPage(1);
|
||||
if (cancelled) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// مقیاس بر اساس عرض ظرف، با در نظر گرفتن devicePixelRatio برای وضوح
|
||||
const containerWidth = canvas.parentElement?.clientWidth || 800;
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const scale = Math.min((containerWidth - 2) / baseViewport.width, 2) * dpr;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height / dpr)}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
if (!cancelled) setLoading(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : "نمایش PDF ناموفق بود");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
doc?.destroy();
|
||||
} catch {
|
||||
/* نادیده بگیر */
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center p-8 text-xs text-gray-500">در حال بارگذاری…</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<div className="text-xs text-gray-600">نمایش پیشنمایش ممکن نشد: {error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="mx-auto block max-w-full rounded-lg border border-gray-200 bg-white shadow-xs" />
|
||||
{pageCount > 1 && (
|
||||
<div className="mt-2 text-center text-[11px] text-gray-500">
|
||||
صفحه ۱ از {pageCount}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
|
||||
|
||||
export interface StatsBarProps {
|
||||
total: number;
|
||||
offered: 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: {
|
||||
key: StatusFilter;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
value: string;
|
||||
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
||||
icon: LucideIcon;
|
||||
}[] = [
|
||||
{
|
||||
key: "all",
|
||||
label: "کل پیشنهادها",
|
||||
shortLabel: "کل",
|
||||
value: fmtNum(total),
|
||||
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
key: "offered",
|
||||
label: "پیشنهاد باز",
|
||||
shortLabel: "باز",
|
||||
value: fmtNum(offered),
|
||||
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
key: "accepted",
|
||||
label: "پذیرفتهشده",
|
||||
shortLabel: "پذیرفته",
|
||||
value: fmtNum(accepted),
|
||||
@@ -39,21 +54,41 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: "میانگین زمان تحویل",
|
||||
shortLabel: "میانگین تحویل",
|
||||
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
|
||||
unit: avgLeadTime != null ? "روز" : undefined,
|
||||
key: "arrived",
|
||||
label: "تحویلشده به کارگاه",
|
||||
shortLabel: "تحویل",
|
||||
value: fmtNum(arrived),
|
||||
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 (
|
||||
<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
|
||||
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">
|
||||
@@ -68,7 +103,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* مقدار عددی به همراه واحد روز */}
|
||||
{/* مقدار عددی */}
|
||||
<div
|
||||
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
||||
>
|
||||
@@ -80,7 +115,8 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use
|
||||
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
||||
export { useMaterials, useAddMaterial } from "./useMaterials";
|
||||
export { useProjects, useAddProject } from "./useProjects";
|
||||
export {
|
||||
useUnits,
|
||||
useSaveRefUnit,
|
||||
useSaveConversion,
|
||||
useDeleteConversion,
|
||||
useSaveStandardConversion,
|
||||
useDeleteStandardConversion,
|
||||
} from "./useUnits";
|
||||
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
||||
|
||||
@@ -1,52 +1,16 @@
|
||||
// ============================================================
|
||||
// hooks واحدها — واحد مرجع، ضرایب دستهای و ثابت
|
||||
// hooks واحدها — تبدیلهای ثابت جهانی (دستهایها حذف شدند)
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types";
|
||||
import type { StandardConversion } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useUnits(): {
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
} {
|
||||
const categoryUnits = useAppStore((s) => s.categoryUnits);
|
||||
const unitConversions = useAppStore((s) => s.unitConversions);
|
||||
export function useUnits(): { standardConversions: StandardConversion[] } {
|
||||
const standardConversions = useAppStore((s) => s.standardConversions);
|
||||
return { categoryUnits, unitConversions, standardConversions };
|
||||
}
|
||||
|
||||
export function useSaveRefUnit() {
|
||||
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
|
||||
onMutate: (cu) => setCategoryUnit(cu),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveConversion() {
|
||||
const setConversion = useAppStore((s) => s.setConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
|
||||
onMutate: (c) => setConversion(c),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversion() {
|
||||
const deleteConversion = useAppStore((s) => s.deleteConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.units.deleteConversion(id),
|
||||
onMutate: (id) => deleteConversion(id),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
return { standardConversions };
|
||||
}
|
||||
|
||||
export function useSaveStandardConversion() {
|
||||
|
||||
@@ -22,8 +22,31 @@ declare global {
|
||||
|
||||
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
|
||||
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
|
||||
const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim();
|
||||
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
|
||||
|
||||
/**
|
||||
* Load the OpenPanel browser script (op1.js) and its queueing shim.
|
||||
*
|
||||
* The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the
|
||||
* script is in flight, so `op("init", …)` can be called immediately and the real
|
||||
* script drains the queue on load. Without it, every `window.op?.()` call here is a
|
||||
* silent no-op — which is what happened before this loader existed.
|
||||
*/
|
||||
function loadOpenPanelScript(src: string): void {
|
||||
if (document.querySelector("script[data-openpanel]")) return;
|
||||
const shim = document.createElement("script");
|
||||
shim.textContent =
|
||||
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();';
|
||||
document.head.appendChild(shim);
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.dataset.openpanel = "1";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
export function initAnalytics(): void {
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
@@ -35,6 +58,7 @@ export function initAnalytics(): void {
|
||||
}
|
||||
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
|
||||
try {
|
||||
if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL);
|
||||
window.op?.("init", {
|
||||
apiUrl: OPENPANEL_URL,
|
||||
clientId: OPENPANEL_CLIENT_ID,
|
||||
|
||||
+90
-115
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import type {
|
||||
Attachment,
|
||||
CategoryUnit,
|
||||
ConfirmedReceipt,
|
||||
InventoryRow,
|
||||
InventoryTransaction,
|
||||
@@ -30,7 +29,6 @@ import type {
|
||||
Store,
|
||||
Supplier,
|
||||
TransactionType,
|
||||
UnitConversion,
|
||||
Warehouse,
|
||||
} from "./types";
|
||||
|
||||
@@ -70,6 +68,20 @@ function authHeaders(init?: RequestInit): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
// 401 سراسری — هر پاسخ 401 یعنی نشست منقضی/نامعتبر است (JWT روی همهی
|
||||
// مسیرها الزامی شد). نشست را پاک میکنیم و به صفحهی ورود میرویم تا بهجای
|
||||
// «برنامهی خاموش» (استور خالی، بدون هیچ خطایی) کاربر به ورود هدایت شود.
|
||||
// دخالت نمیکنیم در: خطای رمز خودِ لاگین، حالت توسعه (هدرهای X-User-*)،
|
||||
// و وقتی از قبل روی /login هستیم (جلوگیری از حلقهی ریدایرکت).
|
||||
function handle401(path: string, res: Response): void {
|
||||
if (res.status !== 401) return;
|
||||
if (path.startsWith("/auth/login")) return;
|
||||
if (useAuthStore.getState().isDev) return;
|
||||
if (window.location.pathname === "/login") return;
|
||||
useAuthStore.getState().logout();
|
||||
window.location.replace("/login");
|
||||
}
|
||||
|
||||
// پروژهی فعالِ مدیر پروژه (چند-پروژهای، 1:N): برای همهی مسیرهای پنل انبار
|
||||
// صریحاً ارسال میشود تا بکاند دادههای همان پروژه را برگرداند/اعتبارسنجی کند.
|
||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||
@@ -186,6 +198,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
} catch {
|
||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||
}
|
||||
handle401(path, res);
|
||||
if (!res.ok) {
|
||||
let msg = `خطای سرور (${res.status})`;
|
||||
let errCode: string | undefined;
|
||||
@@ -237,10 +250,6 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
}
|
||||
async function putData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
}
|
||||
async function deleteData<T>(path: string): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
||||
return res.data;
|
||||
@@ -261,6 +270,7 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
|
||||
} catch {
|
||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
||||
}
|
||||
handle401(path, res);
|
||||
if (!res.ok) {
|
||||
let msg = `خطای سرور (${res.status})`;
|
||||
try {
|
||||
@@ -306,7 +316,9 @@ interface RawSupplier {
|
||||
phone?: string | null;
|
||||
contact_person?: string | null;
|
||||
tax_id?: string | null;
|
||||
categories?: string[] | null; // متریالهای تأمینشده
|
||||
// v2.6: هر تأمینکننده دقیقاً یک دسته دارد (FK) — بکاند این را میفرستد
|
||||
category_id?: string | null;
|
||||
category?: { id: string; slug: string; name: string } | null;
|
||||
}
|
||||
interface RawProject {
|
||||
id: string;
|
||||
@@ -383,21 +395,6 @@ interface RawQuote {
|
||||
line_items?: RawLineItem[] | null;
|
||||
termsheets?: RawTermsheet[] | null;
|
||||
}
|
||||
interface RawCategoryUnit {
|
||||
id: string;
|
||||
category_id: string;
|
||||
ref_unit: string;
|
||||
category?: RawCategory | null;
|
||||
}
|
||||
interface RawUnitConversion {
|
||||
id: string;
|
||||
category_id: string;
|
||||
brand?: string | null;
|
||||
from_unit: string;
|
||||
to_unit: string;
|
||||
factor: number;
|
||||
category?: RawCategory | null;
|
||||
}
|
||||
interface RawStandardConversion {
|
||||
id: string;
|
||||
from_unit: string;
|
||||
@@ -461,6 +458,36 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
|
||||
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
|
||||
}
|
||||
|
||||
// ── تأمینکنندههای متریال — پاسخ GET /materials/{id}/suppliers ──────────────
|
||||
// Raw shape mirrors the backend dict exactly. The backend uses an atomic
|
||||
// column-level JOIN so `name` always comes from the suppliers table.
|
||||
interface RawMaterialSupplier {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
contact_person: string | null;
|
||||
location: string | null;
|
||||
}
|
||||
|
||||
/** Clean UI-facing type for one supplier linked to a material. */
|
||||
export interface MaterialSupplier {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
contact_person: string | null;
|
||||
location: string | null;
|
||||
}
|
||||
|
||||
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
phone: raw.phone,
|
||||
contact_person: raw.contact_person,
|
||||
location: raw.location,
|
||||
};
|
||||
}
|
||||
|
||||
// فایل پیوست — پاسخ بکاند (metadata؛ بایتها از endpoint دانلود میآیند)
|
||||
interface RawAttachment {
|
||||
id: string;
|
||||
@@ -543,7 +570,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
||||
topic:
|
||||
raw.topic ||
|
||||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
||||
category: raw.category?.name || "",
|
||||
brand: raw.brands?.[0]?.name || "",
|
||||
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
||||
specs: (raw.attributes || [])
|
||||
@@ -624,11 +650,13 @@ function adaptQuote(raw: RawQuote): Quote {
|
||||
}
|
||||
|
||||
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
||||
// هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل category
|
||||
// برمیگرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
|
||||
const own = raw.category?.name ? [raw.category.name] : [];
|
||||
return {
|
||||
id: raw.id,
|
||||
topic,
|
||||
// دستههای ذخیرهشده روی تأمینکننده (بکاند) اولویت دارند؛ وگرنه از quotes استنتاج میشود
|
||||
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
|
||||
categories: own.length ? own : categories,
|
||||
name: raw.name,
|
||||
location: raw.location || "",
|
||||
phone: raw.phone || "",
|
||||
@@ -646,44 +674,29 @@ function deriveSupplierTopics(
|
||||
materialsById: Map<string, Material>,
|
||||
): Supplier[] {
|
||||
const topics = new Map<string, Set<string>>();
|
||||
const cats = new Map<string, Set<string>>();
|
||||
for (const q of quotes) {
|
||||
for (const li of q.line_items) {
|
||||
const m = materialsById.get(li.material_id);
|
||||
if (!m) continue;
|
||||
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
||||
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
|
||||
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
|
||||
if (m.category) cats.get(q.supplier_id)!.add(m.category);
|
||||
}
|
||||
}
|
||||
return rawSuppliers.map((s) => {
|
||||
const t = topics.get(s.id);
|
||||
const c = cats.get(s.id);
|
||||
// اگر از quotes topic/category پیدا نشد، registry را چک کن
|
||||
if (!t || t.size === 0) {
|
||||
const derivedTopic = t && t.size ? [...t][0] : "";
|
||||
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
||||
if (!derivedTopic) {
|
||||
const reg = supplierTopicRegistry.get(s.id);
|
||||
if (reg) {
|
||||
return adaptSupplier(s, reg.topic, reg.categories);
|
||||
if (reg) return adaptSupplier(s, reg.topic, reg.categories);
|
||||
}
|
||||
}
|
||||
return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []);
|
||||
// دستهٔ ثبتشده روی خودِ تأمینکننده (FK بکاند) منبع اصلی است و نامش با نام
|
||||
// تاپیکها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج میشود.
|
||||
const backendCat = s.category?.name || "";
|
||||
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
|
||||
});
|
||||
}
|
||||
|
||||
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
|
||||
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
|
||||
}
|
||||
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
|
||||
return {
|
||||
id: raw.id,
|
||||
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
|
||||
brand: raw.brand || "",
|
||||
from_unit: raw.from_unit,
|
||||
to_unit: raw.to_unit,
|
||||
factor: raw.factor,
|
||||
};
|
||||
}
|
||||
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
|
||||
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
|
||||
}
|
||||
@@ -801,16 +814,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
|
||||
export const api = {
|
||||
// ---------- کل store (یک بار در ریشه) ----------
|
||||
async listAll(): Promise<Store> {
|
||||
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] =
|
||||
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
|
||||
await Promise.all([
|
||||
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
|
||||
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
|
||||
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
|
||||
getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
|
||||
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
|
||||
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
|
||||
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
|
||||
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
|
||||
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
|
||||
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
|
||||
]);
|
||||
|
||||
@@ -825,8 +836,6 @@ export const api = {
|
||||
materials,
|
||||
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
|
||||
quotes,
|
||||
categoryUnits: cruRes.map(adaptCategoryUnit),
|
||||
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
|
||||
standardConversions: scuRes.map(adaptStandardConversion),
|
||||
topics: buildTopics(categoriesRes),
|
||||
};
|
||||
@@ -1101,19 +1110,13 @@ export const api = {
|
||||
|
||||
materials: {
|
||||
list: async (): Promise<Material[]> => {
|
||||
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
|
||||
const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
|
||||
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
|
||||
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
|
||||
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
|
||||
},
|
||||
create: async (m: Material): Promise<Material> => {
|
||||
const lookup = await fetchLookup();
|
||||
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
||||
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
||||
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
||||
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
||||
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
||||
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
||||
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
||||
// (بکاند canonical_unit_id را الزامی میداند)
|
||||
const unitName = (m.base_unit || "").trim();
|
||||
@@ -1126,7 +1129,7 @@ export const api = {
|
||||
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
||||
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
||||
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
||||
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||
const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
||||
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -1138,7 +1141,6 @@ export const api = {
|
||||
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
||||
}
|
||||
const raw = await postData<RawMaterial>("/materials", {
|
||||
category_id: categoryId ?? null,
|
||||
name,
|
||||
topic: m.topic || null,
|
||||
canonical_unit_id: unitId,
|
||||
@@ -1154,6 +1156,13 @@ export const api = {
|
||||
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
|
||||
await patchData(`/materials/${id}`, { canonical_unit_id });
|
||||
},
|
||||
// فهرست تأمینکنندههای متصل به یک متریال — GET /materials/{id}/suppliers
|
||||
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
|
||||
const raw = await getData<RawMaterialSupplier[]>(
|
||||
`/materials/${materialId}/suppliers?page_size=100`,
|
||||
);
|
||||
return raw.map(adaptMaterialSupplier);
|
||||
},
|
||||
},
|
||||
|
||||
projects: {
|
||||
@@ -1168,46 +1177,6 @@ export const api = {
|
||||
},
|
||||
|
||||
units: {
|
||||
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
|
||||
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
|
||||
return raw.map(adaptCategoryUnit);
|
||||
},
|
||||
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
|
||||
const lookup = await fetchLookup();
|
||||
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
|
||||
if (!categoryId) throw new Error(`دستهبندی «${cu.category}» یافت نشد`);
|
||||
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
|
||||
},
|
||||
getConversions: async (): Promise<UnitConversion[]> => {
|
||||
const lookup = await fetchLookup();
|
||||
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
|
||||
return raw.map((uc) => adaptUnitConversion(uc, lookup));
|
||||
},
|
||||
saveConversion: async (c: UnitConversion): Promise<void> => {
|
||||
const lookup = await fetchLookup();
|
||||
const categoryId = lookup.categoryNameToId.get(norm(c.category));
|
||||
if (!categoryId) throw new Error(`دستهبندی «${c.category}» یافت نشد`);
|
||||
const body = {
|
||||
category_id: categoryId,
|
||||
brand: c.brand || null,
|
||||
from_unit: c.from_unit,
|
||||
to_unit: c.to_unit,
|
||||
factor: c.factor,
|
||||
};
|
||||
if (looksLikeUuid(c.id)) {
|
||||
await patchData(`/unit-conversions/${c.id}`, {
|
||||
brand: c.brand || null,
|
||||
from_unit: c.from_unit,
|
||||
to_unit: c.to_unit,
|
||||
factor: c.factor,
|
||||
});
|
||||
} else {
|
||||
await postData("/unit-conversions", body);
|
||||
}
|
||||
},
|
||||
deleteConversion: async (id: string): Promise<void> => {
|
||||
await deleteData(`/unit-conversions/${id}`);
|
||||
},
|
||||
getStandardConversions: async (): Promise<StandardConversion[]> => {
|
||||
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
|
||||
return raw.map(adaptStandardConversion);
|
||||
@@ -1286,13 +1255,15 @@ export const api = {
|
||||
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
||||
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
||||
let res: Response;
|
||||
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
|
||||
try {
|
||||
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
|
||||
res = await fetch(BASE_URL + dlPath, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||
}
|
||||
handle401(dlPath, res);
|
||||
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1307,6 +1278,19 @@ export const api = {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
// آدرس blob احراز هویتشده برای پیشنمایش (تصویر/PDF در مودال).
|
||||
// تگهای <img>/<iframe>/<a href> توکن نمیفرستند، پس بایتها را با توکن
|
||||
// آدرس پیشنمایش احراز هویتشده برای <iframe>/<img>.
|
||||
// اینها نمیتوانند هدر Authorization بفرستند، پس بکاند یک توکن
|
||||
// امضاشدهی کوتاهعمر در کوئری میدهد. عمداً blob ساخته نمیشود:
|
||||
// دانلودمنیجرها (IDM) blob را شکار میکنند و فایل ۰ بایتی میسازند،
|
||||
// در حالی که این یک URL واقعی است و درست بارگذاری میشود.
|
||||
previewUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
|
||||
const res = await getData<{ url: string; token: string }>(
|
||||
`/quotes/${quoteId}/preview-url/${kind}/${id}`,
|
||||
);
|
||||
return BASE_URL + res.url;
|
||||
},
|
||||
},
|
||||
|
||||
// ---------- برندها (از بکاند) ----------
|
||||
@@ -1705,7 +1689,8 @@ export const apiV26 = {
|
||||
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
||||
return rawWarehouse(d);
|
||||
},
|
||||
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
|
||||
// project_id عمداً در تایپ نیست — انبار برای همیشه به پروژهی والدش تعلق دارد
|
||||
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
|
||||
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
||||
return rawWarehouse(d);
|
||||
},
|
||||
@@ -1737,15 +1722,6 @@ export const apiV26 = {
|
||||
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
|
||||
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
|
||||
},
|
||||
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
|
||||
const params: Record<string, string> = { category_id };
|
||||
if (warehouse_id) params.warehouse_id = warehouse_id;
|
||||
const pid = activeProjectId();
|
||||
if (pid) params.project_id = pid;
|
||||
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
|
||||
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
pm: {
|
||||
@@ -1783,7 +1759,6 @@ export const apiV26 = {
|
||||
}): Promise<{ id: string; name: string }> =>
|
||||
postData("/materials", {
|
||||
name: opts.name,
|
||||
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
||||
topic: null,
|
||||
canonical_unit_id: opts.canonical_unit_id,
|
||||
status: "active",
|
||||
|
||||
+100
-79
@@ -2,7 +2,7 @@
|
||||
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
|
||||
// ============================================================
|
||||
|
||||
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types";
|
||||
import type { LineItem, Material, Quote, Store, Supplier } from "./types";
|
||||
import { normalize } from "./normalize";
|
||||
import { groupCommas } from "./numutil";
|
||||
import { fmtDate } from "./formatters";
|
||||
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
|
||||
id: "",
|
||||
name: "",
|
||||
topic: "",
|
||||
category: "؟",
|
||||
brand: "",
|
||||
specs: [],
|
||||
base_unit: "",
|
||||
@@ -35,43 +34,51 @@ export function specsKey(specs: string[]): string {
|
||||
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
||||
}
|
||||
|
||||
// specها با جداکنندهی « · »
|
||||
// specها با جداکنندهی « . »
|
||||
export function specsLabel(specs: string[]): string {
|
||||
return specs.join(" · ");
|
||||
return specs.join(" . ");
|
||||
}
|
||||
|
||||
/**
|
||||
* عنوان تمیز و بدون تکرار کالا:
|
||||
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
||||
* ترتیب اکید با جداکنندهی نقطه:
|
||||
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||
*/
|
||||
export function formatMaterialTitle(
|
||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||
m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
|
||||
extraSpecs?: string[],
|
||||
): string {
|
||||
if (!m) return "کالا";
|
||||
const rawName = (m.name || "").trim();
|
||||
let rawName = (m.name || "").trim();
|
||||
const rawBrand = (m.brand || "").trim();
|
||||
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||
.map((s) => (s || "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normName = normalize(rawName);
|
||||
const normBrand = normalize(rawBrand);
|
||||
|
||||
if (!rawName) {
|
||||
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
||||
if (rawBrand) return rawBrand;
|
||||
if (rawSpecs.length) return rawSpecs.join(" · ");
|
||||
return m.category && m.category !== "؟" ? m.category : "کالا";
|
||||
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمیداریم تا تکرار نشود
|
||||
if (normBrand && rawName) {
|
||||
const normName = normalize(rawName);
|
||||
if (normName.startsWith(normBrand)) {
|
||||
const rest = rawName.slice(rawBrand.length).trim();
|
||||
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
let title = rawName;
|
||||
if (normBrand && !normName.includes(normBrand)) {
|
||||
title = `${rawBrand} ${title}`;
|
||||
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
|
||||
const parts: string[] = [];
|
||||
if (rawBrand) {
|
||||
parts.push(rawBrand);
|
||||
}
|
||||
if (rawName) {
|
||||
parts.push(rawName);
|
||||
} else if (!rawBrand) {
|
||||
parts.push("کالا");
|
||||
}
|
||||
|
||||
const normTitle = normalize(title);
|
||||
const distinctSpecs: string[] = [];
|
||||
// اضافه کردن مشخصهها بدون تکرار
|
||||
const normCurrent = normalize(parts.join(" "));
|
||||
const seenNormSpecs = new Set<string>();
|
||||
|
||||
for (const s of rawSpecs) {
|
||||
@@ -79,21 +86,75 @@ export function formatMaterialTitle(
|
||||
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||
seenNormSpecs.add(ns);
|
||||
|
||||
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
||||
if (normTitle.includes(ns)) continue;
|
||||
distinctSpecs.push(s);
|
||||
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمیکنیم
|
||||
if (normCurrent.includes(ns)) continue;
|
||||
parts.push(s);
|
||||
}
|
||||
|
||||
if (distinctSpecs.length > 0) {
|
||||
return `${title} · ${distinctSpecs.join(" · ")}`;
|
||||
}
|
||||
return title;
|
||||
return parts.join(" . ");
|
||||
}
|
||||
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
|
||||
return formatMaterialTitle(m);
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||
// name را به شکل «برند + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا نامِ
|
||||
// پایه باقی بماند.
|
||||
export function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||
const fallback = (name || "").trim();
|
||||
if (!fallback) return "";
|
||||
let base = fallback;
|
||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||
const parts = [brand, ...specs]
|
||||
.map((p) => (p || "").trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const p of parts) {
|
||||
if (!base.includes(p)) continue;
|
||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||
if (!next) continue;
|
||||
base = next;
|
||||
}
|
||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||
if (parts.some((p) => p === base)) return fallback;
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
|
||||
*
|
||||
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
|
||||
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||
* برند و مشخصهی یکسان (مثل دو چمن مصنوعیِ همبرند) را میگیرد. نامِ خالی یعنی
|
||||
* هویت نامعلوم — در آن حالت تطبیق نمیدهیم و متریالِ تازه ساخته میشود.
|
||||
*/
|
||||
export function matchMaterial(
|
||||
materials: Material[],
|
||||
name: string,
|
||||
brand: string,
|
||||
specs: string[],
|
||||
): Material | undefined {
|
||||
const wantName = normalize(name);
|
||||
if (!wantName) return undefined;
|
||||
const wantBrand = normalize(brand);
|
||||
const wantSpecs = specsKey(specs);
|
||||
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
|
||||
const base = normalize(materialBaseName(name, brand, specs));
|
||||
return (
|
||||
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
|
||||
materials.find(
|
||||
(m) =>
|
||||
base &&
|
||||
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
|
||||
sameIdentity(m),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||
export function itemTermsheetIds(li: LineItem): string[] {
|
||||
if (li.termsheet_ids) return li.termsheet_ids;
|
||||
@@ -153,54 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||
|
||||
// واحد مرجع یک دسته
|
||||
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
|
||||
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
|
||||
return cu ? cu.ref_unit : "";
|
||||
}
|
||||
|
||||
// ضریب تبدیل با اولویت: دستهای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
|
||||
export function findConversion(
|
||||
store: Pick<Store, "unitConversions" | "standardConversions">,
|
||||
category: string,
|
||||
brand: string,
|
||||
fromUnit: string,
|
||||
toUnit: string,
|
||||
): UnitConversion | StandardConversion | null {
|
||||
const list = (store.unitConversions || []).filter(
|
||||
(c) =>
|
||||
normalize(c.category) === normalize(category) &&
|
||||
normalize(c.from_unit) === normalize(fromUnit) &&
|
||||
normalize(c.to_unit) === normalize(toUnit) &&
|
||||
(!c.brand || normalize(c.brand) === normalize(brand)),
|
||||
);
|
||||
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
|
||||
if (list[0]) return list[0];
|
||||
return (
|
||||
(store.standardConversions || []).find(
|
||||
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
|
||||
export function refConverted(
|
||||
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
|
||||
li: LineItem,
|
||||
): { qty: number; unit: string; perRef: number | null } | null {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const ref = refUnitForCategory(store, m.category);
|
||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
||||
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
||||
if (!conv) return null;
|
||||
const qty = (li.quantity || 0) * conv.factor;
|
||||
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
||||
const perRef = qty > 0 ? Math.round(total / qty) : null;
|
||||
return { qty, unit: ref, perRef };
|
||||
}
|
||||
|
||||
// واحدهای ناشناختهی استفادهشده در یک quote → لینک به داشبورد واحدها
|
||||
// واحدها دیگر hardcode نیستند — همه از بکاند میآیند؛ هشداری نمایش داده نمیشود
|
||||
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
|
||||
@@ -270,9 +283,8 @@ export function termsheetSummary(store: Store, q: Quote): { title: string; sub:
|
||||
// فیلترهای دفتر
|
||||
export function filterQuotes(
|
||||
store: Store,
|
||||
filters: { category: string; supplier: string; status: string; search: string },
|
||||
filters: { supplier: string; status: string; search: string },
|
||||
): Quote[] {
|
||||
const catVal = filters.category.trim();
|
||||
const supVal = filters.supplier.trim();
|
||||
const stVal = filters.status.trim();
|
||||
const q = filters.search.trim().toLowerCase();
|
||||
@@ -281,8 +293,13 @@ export function filterQuotes(
|
||||
"پیشنهاد شده": "offered",
|
||||
"پذیرفته شده": "accepted",
|
||||
"رسیده": "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) => {
|
||||
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
||||
@@ -290,12 +307,11 @@ export function filterQuotes(
|
||||
if (stKey && quote.status !== stKey) return false;
|
||||
|
||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
||||
|
||||
if (q) {
|
||||
const hay =
|
||||
`${supObj.name} ${quote.project || ""} ` +
|
||||
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
if (!hay.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -306,11 +322,16 @@ export function filterQuotes(
|
||||
export function isExpired(q: Quote): boolean {
|
||||
return (
|
||||
q.status === "offered" &&
|
||||
!quoteHasArrivedOrAccepted(q) &&
|
||||
!!q.expiry_date &&
|
||||
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
||||
);
|
||||
}
|
||||
|
||||
function quoteHasArrivedOrAccepted(q: Quote): boolean {
|
||||
return q.status === "accepted" || q.status === "arrived";
|
||||
}
|
||||
|
||||
// آمادهسازی نمایش ترمشیت در حالت expanded
|
||||
export function termSheetBlocks(
|
||||
store: Store,
|
||||
|
||||
@@ -20,9 +20,8 @@ export interface Supplier {
|
||||
// ماده/کالا
|
||||
export interface Material {
|
||||
id: string; // "m1", "m2", ...
|
||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه)
|
||||
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
||||
topic: string;
|
||||
category: string; // مثلاً "میلگرد"
|
||||
brand: string; // مثلاً "ذوب آهن"
|
||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||
base_unit: string; // واحد پایه
|
||||
@@ -97,23 +96,6 @@ export interface Quote {
|
||||
termsheets: Termsheet[];
|
||||
}
|
||||
|
||||
// واحد مرجع هر دسته
|
||||
export interface CategoryUnit {
|
||||
id: string;
|
||||
category: string;
|
||||
ref_unit: string;
|
||||
}
|
||||
|
||||
// ضریب تبدیل (دستهای)
|
||||
export interface UnitConversion {
|
||||
id: string;
|
||||
category: string;
|
||||
brand: string; // خالی = همه برندها
|
||||
from_unit: string;
|
||||
to_unit: string;
|
||||
factor: number; // 1 from_unit = factor * to_unit
|
||||
}
|
||||
|
||||
// ضریب تبدیل استاندارد (جهانی)
|
||||
export interface StandardConversion {
|
||||
id: string;
|
||||
@@ -128,8 +110,6 @@ export interface Store {
|
||||
materials: Material[];
|
||||
projects: Project[];
|
||||
quotes: Quote[];
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
topics: Topic[]; // دستهبندیهای ۸گانه از /material-categories
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface AcceptModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها
|
||||
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
@@ -51,13 +50,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
// تخصیص هر قلم به پروژهها (نامها)
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||
// تاریخ و زمان پذیرش — پیشفرض الان
|
||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// وقتی quote باز شد، state اولیه را بساز
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const offered = q.termsheets.filter((t) => t.status === "offered");
|
||||
@@ -70,7 +66,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
});
|
||||
setRemovedItems(rem);
|
||||
setAssignments({});
|
||||
// پروژهی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژهی پیشنهاد
|
||||
const projSel: Record<string, string> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
@@ -86,7 +81,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
// پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
@@ -109,7 +103,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const allItems = q.line_items || [];
|
||||
const active = activeItems(q);
|
||||
|
||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها
|
||||
const projectOptions = (() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
@@ -147,10 +140,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const confirm = () => {
|
||||
if (!q) return;
|
||||
|
||||
// پچ نهایی — همهی تغییرات روی همین کلون اعمال میشود (نه روی آبجکت store)
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// فقط کارتهایی با حداقل یک مرحلهی پرداخت
|
||||
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
||||
cards.forEach((c, idx) => {
|
||||
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
||||
@@ -164,11 +155,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
});
|
||||
|
||||
if (!cardMeta.size) {
|
||||
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
|
||||
toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
|
||||
return;
|
||||
}
|
||||
|
||||
// رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند
|
||||
next.line_items.forEach((li) => {
|
||||
if (removedItems.has(li.id)) li.rejected = true;
|
||||
});
|
||||
@@ -178,7 +168,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند
|
||||
const fallbackDom = [...cardMeta.keys()][0];
|
||||
const chosenDomIds = new Set<string>();
|
||||
active.forEach((li) => {
|
||||
@@ -200,7 +189,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود
|
||||
active.forEach((li) => {
|
||||
const dom = assignments[li.id] || fallbackDom;
|
||||
const finalId = domToFinal[dom];
|
||||
@@ -210,7 +198,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
li.accepted_ts_id = finalId || null;
|
||||
});
|
||||
|
||||
// termsheet پذیرفتهشدهای که هیچ قلمی انتخابش نکرده → برمیگردد به offered
|
||||
const usedIds = new Set<string>();
|
||||
active.forEach((li) => {
|
||||
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
||||
@@ -242,26 +229,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||
<div className="flex flex-col max-h-[85vh] w-full">
|
||||
|
||||
<div className="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>
|
||||
|
||||
{/* کارتهای 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) => (
|
||||
<div key={c.domId} className="modal-variant">
|
||||
<div className="modal-variant-head">
|
||||
<span className="variant-num">{cardLabel(c, idx)}</span>
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
|
||||
<div className="modal-variant-head mb-2 flex items-center justify-between">
|
||||
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
|
||||
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{c.milestones.map((m, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
@@ -275,31 +273,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
|
||||
+ نوشتن conditions جدید
|
||||
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
|
||||
+ افزودن شرایط پرداخت جدید
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* تخصیص قلمها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به conditions:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
{/* بخش ۲: تخصیص اقلام */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
|
||||
<div className="mb-4 text-[12px] text-muted">
|
||||
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{allItems.map((li) => {
|
||||
const removed = removedItems.has(li.id);
|
||||
const m = materialById(materials, li.material_id);
|
||||
return (
|
||||
<div key={li.id} className={`accept-item-assign-row ${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
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
|
||||
checked={!removed}
|
||||
title="تیک = در معامله میماند"
|
||||
onChange={(e) => {
|
||||
@@ -309,16 +311,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
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)}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted">
|
||||
</div>
|
||||
<div className="text-[12px] text-muted mt-0.5">
|
||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 || ""}
|
||||
onChange={(e) => assign(li.id, e.target.value)}
|
||||
disabled={removed}
|
||||
>
|
||||
{cards.map((c, idx) => (
|
||||
<option key={c.domId} value={c.domId}>
|
||||
@@ -330,35 +336,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
</section>
|
||||
|
||||
{/* تخصیص قلمها به پروژه */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">پروژهی هر قلم:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
برای هر متریال فقط یک پروژه انتخاب میشود — مثل شرایط پرداخت.
|
||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||
<div className="mb-4 text-[12px] text-muted">
|
||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
return (
|
||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||
<div className="mb-1.5 text-[12.5px]">
|
||||
<span className="font-semibold">
|
||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
||||
{formatMaterialTitle(m)}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
{projectOptions.length ? (
|
||||
<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] ?? ""}
|
||||
onChange={(e) =>
|
||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||
@@ -376,17 +384,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
))}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{/* فاکتورهای اصلی */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
@@ -398,12 +407,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
)
|
||||
}
|
||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* پیشفاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||
<div className="h-px bg-line w-full"></div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیشفاکتورها</h3>
|
||||
<FileList
|
||||
files={proformas}
|
||||
onChange={setProformas}
|
||||
@@ -415,24 +426,34 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
)
|
||||
}
|
||||
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
|
||||
label="افزودن پیشفاکتور"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخ و زمان پذیرش */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="field max-w-xs">
|
||||
<label>تاریخ و زمان پذیرش</label>
|
||||
<div className="h-px bg-line w-full"></div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
|
||||
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</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 onClick={confirm}>ثبت معامله بستهشده</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -11,16 +11,15 @@ import {
|
||||
} from "../../hooks";
|
||||
import { useAppStore } from "../../store";
|
||||
import { filterQuotes } from "../../lib/quoteUtils";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { StatsBar } from "../../components/StatsBar";
|
||||
import { StatsBar, type StatusFilter } from "../../components/StatsBar";
|
||||
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||
import { LedgerTable } from "./LedgerTable";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { AcceptModal } from "./AcceptModal";
|
||||
import { EditModal } from "./EditModal";
|
||||
|
||||
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
||||
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
||||
|
||||
export function DashboardPage() {
|
||||
useStoreData();
|
||||
@@ -29,7 +28,7 @@ export function DashboardPage() {
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const { standardConversions } = useUnits();
|
||||
|
||||
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
||||
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
||||
@@ -41,8 +40,8 @@ export function DashboardPage() {
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
|
||||
const store = useMemo(
|
||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
||||
() => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
|
||||
[quotes, suppliers, materials, projects, standardConversions, topics],
|
||||
);
|
||||
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at)
|
||||
@@ -56,19 +55,31 @@ export function DashboardPage() {
|
||||
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||
}, [store, filters, sortDir]);
|
||||
|
||||
// شمارش تفکیکی پیشنهادها بر اساس پایپلاین خرید
|
||||
const stats = useMemo(() => {
|
||||
const total = quotes.length;
|
||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||
const leadTimes = quotes
|
||||
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||
.filter((x): x is number => x != null);
|
||||
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
|
||||
return { total, offered, accepted, avgLead };
|
||||
const arrived = quotes.filter((q) => q.status === "arrived").length;
|
||||
return { total, offered, accepted, arrived };
|
||||
}, [quotes]);
|
||||
|
||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||
// وضعیت فعال در 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 supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||
|
||||
return (
|
||||
@@ -99,7 +110,7 @@ export function DashboardPage() {
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||
>
|
||||
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||
<span>ضرایب تبدیل</span>
|
||||
<span>اقلام و ضرایب</span>
|
||||
</Link>
|
||||
|
||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||
@@ -119,12 +130,13 @@ export function DashboardPage() {
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
avgLeadTime={stats.avgLead}
|
||||
arrived={stats.arrived}
|
||||
activeFilter={activeStatusFilter}
|
||||
onFilterChange={handleStatusFilterChange}
|
||||
/>
|
||||
<FilterToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
categories={categoryOptions}
|
||||
suppliers={supplierOptions}
|
||||
sortDir={sortDir}
|
||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||
@@ -14,17 +14,16 @@ import {
|
||||
useAddSupplier,
|
||||
useAddMaterial,
|
||||
} from "../../hooks";
|
||||
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
interface EditItemRow {
|
||||
key: string;
|
||||
origLi?: LineItem;
|
||||
projects?: { id: string; name: string }[];
|
||||
cat: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
@@ -53,7 +51,7 @@ interface EditItemRow {
|
||||
|
||||
interface EditTsCard {
|
||||
key: string;
|
||||
domId: string; // "" یا "new-ts-..." برای کارت جدید
|
||||
domId: string;
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
@@ -62,7 +60,6 @@ export interface EditModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال ویرایش کامل پیشنهاد — بهینهشده برای صفحات موبایل
|
||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
@@ -84,10 +81,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
}, [q]);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
|
||||
const [supplierName, setSupplierName] = useState("");
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||
@@ -113,7 +112,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// گزینههای مشخصه
|
||||
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -134,7 +132,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials, itemRows, globalSpecs]);
|
||||
|
||||
// گزینههای پروژه
|
||||
const projectOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
@@ -149,17 +146,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
if (!q) return;
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
setSupplierName(sup.name);
|
||||
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
||||
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]);
|
||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
||||
setNotes(q.notes || "");
|
||||
setItemRows(
|
||||
q.line_items.map((li) => {
|
||||
@@ -167,8 +154,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return {
|
||||
key: li.id,
|
||||
origLi: li,
|
||||
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||||
cat: m.category || "",
|
||||
cat: m.name || "",
|
||||
brand: m.brand || "",
|
||||
specs: materialSpecs(m),
|
||||
qty: li.quantity,
|
||||
@@ -197,7 +183,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
setReady(true);
|
||||
}, [q, suppliers, materials]);
|
||||
|
||||
// بارگذاری فاکتورها
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
@@ -205,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
void api.files
|
||||
.list(quoteId, "proforma")
|
||||
.then((list) => alive && setProformas(list))
|
||||
.catch(() => alive && setProformas([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [quoteId]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
const itemOptions = useMemo(() => {
|
||||
return itemRows
|
||||
.filter((r) => !r.rejected || r.restore)
|
||||
.map((r, idx) => ({
|
||||
tempId: r.key,
|
||||
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
|
||||
}));
|
||||
}, [itemRows]);
|
||||
|
||||
const tsLabel = (card: EditTsCard, idx: number) => {
|
||||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
||||
const getCoveredIds = (card: EditTsCard) => {
|
||||
const cardId = card.domId || card.key;
|
||||
const explicit = itemRows
|
||||
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
|
||||
.map((r) => r.key);
|
||||
|
||||
if (!explicit.length && tsCards[0]?.key === card.key) {
|
||||
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
|
||||
}
|
||||
return explicit;
|
||||
};
|
||||
|
||||
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
|
||||
const card = tsCards.find((c) => c.key === cardKey);
|
||||
const cardId = card?.domId || cardKey;
|
||||
setItemRows((rows) =>
|
||||
rows.map((row) => {
|
||||
const shouldHave = newCoveredKeys.includes(row.key);
|
||||
const exists = row.tsDomIds.includes(cardId);
|
||||
if (shouldHave === exists) return row;
|
||||
const nextTsDomIds = shouldHave
|
||||
? [...row.tsDomIds, cardId]
|
||||
: row.tsDomIds.filter((id) => id !== cardId);
|
||||
return { ...row, tsDomIds: nextTsDomIds };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addItemRow = () => {
|
||||
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
|
||||
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
|
||||
setItemRows((rows) => [
|
||||
...rows,
|
||||
{
|
||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
||||
key: newKey,
|
||||
cat: "",
|
||||
brand: "",
|
||||
specs: [],
|
||||
@@ -233,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
unit: "",
|
||||
price: null,
|
||||
total: null,
|
||||
tsDomIds: [],
|
||||
tsDomIds: defaultTsId ? [defaultTsId] : [],
|
||||
acceptedTsId: null,
|
||||
rejected: false,
|
||||
restore: true,
|
||||
@@ -248,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const addTsCard = () => {
|
||||
setTsCards((cards) => [
|
||||
...cards,
|
||||
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
||||
{
|
||||
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
|
||||
domId: "",
|
||||
milestones: [{ when_iso: null, amount: null }],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTsCard = (key: string) => {
|
||||
if (tsCards.length <= 1) {
|
||||
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
|
||||
return;
|
||||
}
|
||||
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
||||
const card = tsCards.find((c) => c.key === key);
|
||||
const cardId = card?.domId || key;
|
||||
setItemRows((rows) =>
|
||||
rows.map((r) => ({
|
||||
...r,
|
||||
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
@@ -264,7 +303,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// تأمینکننده
|
||||
if (supplierName.trim()) {
|
||||
const norm = normalize(supplierName);
|
||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||
@@ -281,8 +319,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
next.projects = [...selectedProjects];
|
||||
next.notes = notes.trim();
|
||||
next.invoices = invoices;
|
||||
next.proformas = proformas;
|
||||
|
||||
// termsheetها
|
||||
const tsIdMap: Record<string, string> = {};
|
||||
const newTermsheets: Termsheet[] = [];
|
||||
tsCards.forEach((card, i) => {
|
||||
@@ -292,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
? domId
|
||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||
tsIdMap[domId] = realId;
|
||||
tsIdMap[card.key] = realId;
|
||||
const orig = next.termsheets.find((t) => t.id === realId);
|
||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||
const st = orig?.status || "offered";
|
||||
@@ -299,7 +338,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
});
|
||||
next.termsheets = newTermsheets;
|
||||
|
||||
// اقلام
|
||||
const newItems: LineItem[] = [];
|
||||
const materialsCopy = [...materials];
|
||||
for (const row of itemRows) {
|
||||
@@ -312,12 +350,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const total = row.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
let material = materialsCopy.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
@@ -326,7 +359,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
id: nextId("m", materialsCopy),
|
||||
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
@@ -344,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
if (!rejected) {
|
||||
row.tsDomIds.forEach((domId) => {
|
||||
const realId = tsIdMap[domId] || domId;
|
||||
if (realId) termsheet_ids.push(realId);
|
||||
if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
|
||||
});
|
||||
if (!termsheet_ids.length && next.termsheets.length) {
|
||||
termsheet_ids.push(next.termsheets[0].id);
|
||||
@@ -361,15 +393,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
acceptedTsId = null;
|
||||
}
|
||||
|
||||
const candProjs =
|
||||
row.projects && row.projects.length
|
||||
? row.projects
|
||||
: row.origLi?.projects && row.origLi.projects.length
|
||||
? row.origLi.projects
|
||||
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
||||
|
||||
newItems.push({
|
||||
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
|
||||
id: "li-" + (newItems.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
@@ -379,7 +404,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
accepted_ts_id: acceptedTsId,
|
||||
rejected,
|
||||
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_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
||||
});
|
||||
@@ -388,14 +413,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
const referencedTs = new Set<string>();
|
||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
||||
next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||
li.accepted_ts_id = null;
|
||||
}
|
||||
});
|
||||
|
||||
// تاریخها و وضعیت
|
||||
next.quote_date = quoteDate || q.quote_date;
|
||||
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
||||
next.accepted_date = acceptedDate;
|
||||
@@ -435,23 +459,102 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
|
||||
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
||||
{/* هدر */}
|
||||
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{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">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
|
||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* اطلاعات بالایی — تکستونه در موبایل، ۲ ستونه در دسکتاپ */}
|
||||
{/* تببار */}
|
||||
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("items")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||
activeTab === "items"
|
||||
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||
)}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
<span>اقلام و سفارش</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{faNum(itemRows.length)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("terms")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||
activeTab === "terms"
|
||||
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||
)}
|
||||
>
|
||||
<CreditCard className="h-3.5 w-3.5" />
|
||||
<span>شرایط پرداخت</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{faNum(tsCards.length)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("dates")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||
activeTab === "dates"
|
||||
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>وضعیت و اسناد</span>
|
||||
{invoices.length > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{faNum(invoices.length)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* محتوا */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||
{/* تب ۱: اقلام */}
|
||||
{activeTab === "items" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تأمینکننده</label>
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">تأمینکننده</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||
@@ -462,11 +565,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
onInputChange={(v) => setSupplierName(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">پروژه</label>
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">پروژه</label>
|
||||
<MultiSelectCombobox
|
||||
values={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
@@ -476,113 +577,149 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">یادداشت</label>
|
||||
<textarea
|
||||
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}
|
||||
placeholder="یادداشتهای داخلی این معامله..."
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اقلام */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{itemRows.map((row) => (
|
||||
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||
</div>
|
||||
|
||||
{itemRows.map((row, idx) => (
|
||||
<div
|
||||
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="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
|
||||
<label className="text-[11px] font-semibold text-muted">متریال</label>
|
||||
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||
{faNum(idx + 1)}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-900 truncate">
|
||||
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">حذف</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* ردیف ۱: متریال و برند */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</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="متریال"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="نام متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* برند */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">برند</label>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</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"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* واحد */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">واحد</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="واحد"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* مشخصه — تمامعرض در موبایل */}
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1">
|
||||
<label className="text-[11px] font-semibold text-muted">مشخصه</label>
|
||||
<div className="w-full min-w-0">
|
||||
{/* ردیف ۲: مشخصه فنی (تمامعرض با فضای کافی برای تگها) */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
placeholder="انتخاب مشخصات..."
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تعداد */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">تعداد</label>
|
||||
{/* بخش مقادیر و محاسبات مالی */}
|
||||
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
|
||||
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
placeholder="مقدار"
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* قیمت واحد */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۴: قیمت هر واحد (تمامعرض با نمایش حروف) */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
|
||||
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||
</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
price,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
placeholder="قیمت هر واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* قیمت کل */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
|
||||
{/* ردیف ۵: قیمت کل این قلم (تمامعرض با نمایش حروف) */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.total}
|
||||
@@ -593,123 +730,133 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
placeholder="قیمت کل قلم"
|
||||
showWords
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* تخصیص termsheet */}
|
||||
<div className="mt-2.5 border-t border-line/60 pt-2">
|
||||
{row.rejected ? (
|
||||
<label className="ts-cb-label e-restore">
|
||||
{row.rejected && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-600 h-4 w-4 rounded"
|
||||
checked={row.restore}
|
||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||
/>
|
||||
بازگرداندن به معامله
|
||||
بازگرداندن این قلم به معامله
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={hasReceipts}
|
||||
className="me-1 accent-accent disabled:cursor-not-allowed"
|
||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(row.tsDomIds);
|
||||
const domId = card.domId || card.key;
|
||||
if (e.target.checked) next.add(domId);
|
||||
else next.delete(domId);
|
||||
patchItem(row.key, { tsDomIds: [...next] });
|
||||
}}
|
||||
/>
|
||||
{tsLabel(card, idx)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
|
||||
{/* termsheetها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div key={card.key} className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||
{!hasReceipts && (
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{card.milestones.map((ms, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={ms}
|
||||
disabled={hasReceipts}
|
||||
onChange={(nextMs) =>
|
||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||||
}
|
||||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
|
||||
{activeTab === "terms" && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||
<div className="text-[11.5px] text-gray-500 mt-0.5">
|
||||
روشهای پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
|
||||
</div>
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
className="gap-1 text-xs text-gray-700"
|
||||
onClick={addTsCard}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>افزودن گزینه جدید</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{tsCards.map((card, idx) => (
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
index={idx + 1}
|
||||
milestones={card.milestones}
|
||||
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
|
||||
items={itemOptions}
|
||||
coveredIds={getCoveredIds(card)}
|
||||
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
|
||||
onRemove={() => removeTsCard(card.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* تب ۳: وضعیت و اسناد */}
|
||||
{activeTab === "dates" && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
value={status}
|
||||
disabled={hasReceipts}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{STATUS_LABEL[st]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
|
||||
<div className="text-xs font-bold text-gray-900">تاریخهای چرخه خرید</div>
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
|
||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
|
||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوستها</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
@@ -721,72 +868,54 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
)
|
||||
}
|
||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||
/>
|
||||
</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 className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||
<div className="text-xs font-bold text-gray-900">پیشفاکتورها</div>
|
||||
<FileList
|
||||
files={proformas}
|
||||
onChange={setProformas}
|
||||
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
|
||||
label="افزودن پیشفاکتور"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
|
||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
|
||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={status}
|
||||
disabled={hasReceipts}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{STATUS_LABEL[st]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hasReceipts && (
|
||||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||||
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* فوتر و دکمههای اقدام */}
|
||||
<DialogFooter className="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" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
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" />
|
||||
<span>حذف پیشنهاد</span>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">حذف</span>
|
||||
</Button>
|
||||
) : (
|
||||
<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 onClick={() => void confirm()} className="flex-1 sm:flex-initial">
|
||||
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
|
||||
ذخیره تغییرات
|
||||
</Button>
|
||||
</div>
|
||||
@@ -794,19 +923,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* مودال تأیید حذف */}
|
||||
{/* مودال تایید حذف */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6">
|
||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
||||
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2 text-sm text-muted">
|
||||
<div className="py-2 text-xs sm:text-sm text-gray-600">
|
||||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="slate"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
@@ -815,6 +945,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
>
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
||||
|
||||
export interface Filters {
|
||||
category: string;
|
||||
supplier: string;
|
||||
status: 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 {
|
||||
filters: Filters;
|
||||
onChange: (f: Filters) => void;
|
||||
categories: string[];
|
||||
suppliers: { id: string; name: string }[];
|
||||
sortDir: "desc" | "asc";
|
||||
onToggleSort: () => void;
|
||||
@@ -25,7 +47,6 @@ export interface FilterToolbarProps {
|
||||
export function FilterToolbar({
|
||||
filters,
|
||||
onChange,
|
||||
categories,
|
||||
suppliers,
|
||||
sortDir,
|
||||
onToggleSort,
|
||||
@@ -34,10 +55,18 @@ export function FilterToolbar({
|
||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||
|
||||
// شمارش تعداد فیلترهای فعال به جز سرچ
|
||||
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length;
|
||||
const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length;
|
||||
|
||||
const clearFilters = () => {
|
||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
||||
onChange({ ...filters, 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 (
|
||||
@@ -121,18 +150,6 @@ export function FilterToolbar({
|
||||
|
||||
{/* دراپداونها داخل کشو */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
|
||||
<Combobox
|
||||
value={filters.category}
|
||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
||||
items={categories}
|
||||
allowAdd={false}
|
||||
placeholder="همه دستهها"
|
||||
onInputChange={(v) => set({ category: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">تأمینکننده</label>
|
||||
<Combobox
|
||||
@@ -148,12 +165,12 @@ export function FilterToolbar({
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||
<Combobox
|
||||
value={filters.status}
|
||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||
items={STATUS_ITEMS}
|
||||
value={statusDisplayValue}
|
||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||
items={STATUS_OPTIONS}
|
||||
allowAdd={false}
|
||||
placeholder="همه وضعیتها"
|
||||
onInputChange={(v) => set({ status: v })}
|
||||
onInputChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,17 +189,6 @@ export function FilterToolbar({
|
||||
|
||||
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
|
||||
<div className="hidden md:flex flex-wrap items-end gap-3">
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>دسته</label>
|
||||
<Combobox
|
||||
value={filters.category}
|
||||
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
||||
items={categories}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ category: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[170px] flex-1">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
@@ -197,12 +203,12 @@ export function FilterToolbar({
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>وضعیت</label>
|
||||
<Combobox
|
||||
value={filters.status}
|
||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||
items={STATUS_ITEMS}
|
||||
value={statusDisplayValue}
|
||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||
items={STATUS_OPTIONS}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ status: v })}
|
||||
onInputChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[200px] flex-1">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialLabel,
|
||||
projectCell,
|
||||
@@ -36,7 +35,6 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
|
||||
);
|
||||
}
|
||||
|
||||
// کارت پیشنهاد خرید — بهینهشده برای کاربری سرپا با موبایل
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
@@ -44,14 +42,16 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
const pc = priceCell(total);
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
// ستون / دکمه پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button
|
||||
onClick={onAccept}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -136,39 +136,81 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت */}
|
||||
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
|
||||
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند میشود */}
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* خط ۱: فقط خود اسم تأمینکننده لینک است */}
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||
|
||||
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
{sup.location && (
|
||||
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
||||
{sup.location}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{q.is_official && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
|
||||
<span>فاکتور رسمی</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||
{fulfillmentBadge ?? (
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||
{STATUS_LABEL[q.status]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* دکمه ویرایش */}
|
||||
<button
|
||||
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
title="ویرایش پیشنهاد"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
<Pencil className="h-3 w-3 text-muted" />
|
||||
<span className="hidden sm:inline">ویرایش</span>
|
||||
</button>
|
||||
|
||||
{/* دکمه فلش اکسپند در هدر */}
|
||||
<button
|
||||
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
|
||||
expanded && "bg-accent-bg text-accent-dark border-accent/40",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
@@ -176,9 +218,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
||||
{/* ۱. پروژه — تمامعرض در موبایل */}
|
||||
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند میکند */}
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
|
||||
>
|
||||
{/* ۱. پروژه */}
|
||||
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
@@ -188,52 +233,28 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
||||
{/* ۲. اقلام */}
|
||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-bold text-xs text-ink sm:text-sm">
|
||||
{fmtNum(summary.count)} قلم کالا
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
||||
{store.quotes
|
||||
.find((x) => x.id === q.id)
|
||||
?.line_items?.filter((li) => !li.rejected)
|
||||
.slice(0, 3)
|
||||
.map((li) => {
|
||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
{activeItems.length > 0 ? (
|
||||
activeItems.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
||||
>
|
||||
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
||||
• {materialLabel(m)}
|
||||
<span key={li.id} className="break-words">
|
||||
{materialLabel(m)}
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
||||
{fmtNum(li.quantity)}{" "}
|
||||
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(q.line_items?.length || 0) > 3 && (
|
||||
<span className="pt-0.5 text-[10px] text-muted">
|
||||
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
<span className="break-words">{summary.text}</span>
|
||||
</div>
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
@@ -243,32 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
))}
|
||||
</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">
|
||||
{pc ? (
|
||||
<div>
|
||||
@@ -325,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* جزئیات تکمیلی بازشونده */}
|
||||
{expanded && <QuoteDetail store={store} q={q} />}
|
||||
{/* دکمه تمامعرض توضیحات بیشتر */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
|
||||
>
|
||||
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
|
||||
</button>
|
||||
|
||||
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته میشود */}
|
||||
{expanded && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<QuoteDetail store={store} q={q} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
refConverted,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
@@ -25,6 +25,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
|
||||
// پیشنمایش پیوستها: کلیک روی نام فایل، فایل را نشان میدهد (نه دانلود).
|
||||
// بایتها با توکن گرفته میشوند چون <img>/<iframe> نمیتوانند هدر بفرستند.
|
||||
// کلید با پیشوند نوع پیوست ساخته میشود تا نوعش گم نشود.
|
||||
const preview = useFilePreview((key, name) => {
|
||||
const [kind, id] = key.split(":") as ["invoice" | "proforma", string];
|
||||
void name;
|
||||
return api.files.previewUrl(q.id, kind, id);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
||||
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
||||
@@ -66,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
||||
const total = lineItemTotal(li);
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
|
||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||
@@ -130,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{conv && (
|
||||
<div className="fb-conv">
|
||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
||||
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -144,31 +146,35 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
attachments.proformas.forEach((pf, i) => {
|
||||
if (!pf.id) return;
|
||||
attachParts.push(
|
||||
<span key={"pf" + i} className="inline-flex items-center">
|
||||
<button
|
||||
key={"pf" + i}
|
||||
type="button"
|
||||
className="detail-link"
|
||||
title="دانلود پیشفاکتور"
|
||||
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
||||
title="مشاهده پیشفاکتور"
|
||||
disabled={preview.loadingId === `proforma:${pf.id}`}
|
||||
onClick={() => void preview.open(`proforma:${pf.id}`, pf.name)}
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</button>,
|
||||
</button>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
attachments.invoices.forEach((inv, i) => {
|
||||
if (!inv.id) return;
|
||||
attachParts.push(
|
||||
<span key={"inv" + i} className="inline-flex items-center">
|
||||
<button
|
||||
key={"inv" + i}
|
||||
type="button"
|
||||
className="detail-link"
|
||||
title="دانلود فاکتور"
|
||||
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
||||
title="مشاهده فاکتور"
|
||||
disabled={preview.loadingId === `invoice:${inv.id}`}
|
||||
onClick={() => void preview.open(`invoice:${inv.id}`, inv.name)}
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور اصلی: {inv.name}
|
||||
</button>,
|
||||
</button>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -251,6 +257,9 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{preview.item && (
|
||||
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export function ChangeInventoryPage() {
|
||||
let m = matByName(matName);
|
||||
if (!m) {
|
||||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||||
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
|
||||
m = { id: created.id, name: created.name, canonical_unit: null };
|
||||
}
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
import { InventoryTopNav } from "./InventoryTopNav";
|
||||
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
||||
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
|
||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
@@ -55,8 +55,7 @@ export function InventoryPage() {
|
||||
const isPM = user?.role === "pm";
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
||||
category: "",
|
||||
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||
warehouse: params.get("warehouse") || "",
|
||||
material: "",
|
||||
project: "",
|
||||
@@ -81,7 +80,6 @@ export function InventoryPage() {
|
||||
inventory.forEach((r) => {
|
||||
const whId = r.warehouse?.id;
|
||||
if (!whId || !visibleIds.has(whId)) return;
|
||||
if (filters.category && r.material.category?.id !== filters.category) return;
|
||||
if (filters.warehouse && whId !== filters.warehouse) return;
|
||||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||
@@ -132,7 +130,7 @@ export function InventoryPage() {
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
|
||||
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project));
|
||||
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (!user || (!isAdmin && !isPM)) {
|
||||
@@ -248,40 +246,6 @@ export function InventoryPage() {
|
||||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||
{showMobileFilters && (
|
||||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||
{/* دستهبندیها */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
!filters.category
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
همه دستهبندیها
|
||||
</button>
|
||||
{lookup.categories.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.category === c.id
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* پروژهها (مخصوص ادمین) */}
|
||||
{isAdmin && lookup.projects.length > 0 && (
|
||||
<div>
|
||||
@@ -324,7 +288,7 @@ export function InventoryPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
پاک کردن فیلترها
|
||||
@@ -347,7 +311,6 @@ export function InventoryPage() {
|
||||
) : (
|
||||
groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<InventoryMobileCard
|
||||
key={g.key}
|
||||
@@ -355,7 +318,6 @@ export function InventoryPage() {
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
@@ -410,7 +372,6 @@ export function InventoryPage() {
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<GroupRow
|
||||
key={g.key}
|
||||
@@ -418,7 +379,6 @@ export function InventoryPage() {
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
@@ -475,7 +435,6 @@ function InventoryMobileCard({
|
||||
isExp,
|
||||
isAdmin,
|
||||
matName,
|
||||
matCatName,
|
||||
wh,
|
||||
projName,
|
||||
unit,
|
||||
@@ -486,7 +445,6 @@ function InventoryMobileCard({
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
@@ -508,11 +466,6 @@ function InventoryMobileCard({
|
||||
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||
{matName}
|
||||
</h3>
|
||||
{matCatName !== "—" && (
|
||||
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
||||
{matCatName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -574,12 +527,11 @@ function InventoryMobileCard({
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||
function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
@@ -591,7 +543,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
|
||||
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
|
||||
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
|
||||
<td className="py-2.5 pe-3">
|
||||
<div>{wh}</div>
|
||||
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
|
||||
|
||||
@@ -148,16 +148,8 @@ export function OrganizationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const moveWarehouse = async (id: string, projectId: string) => {
|
||||
try {
|
||||
await apiV26.warehouses.update(id, { project_id: projectId });
|
||||
toast.success("انبار منتقل شد ✓");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
|
||||
}
|
||||
};
|
||||
|
||||
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
||||
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
||||
const removeWarehouse = async (id: string) => {
|
||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||
try {
|
||||
@@ -329,16 +321,6 @@ export function OrganizationPage() {
|
||||
>
|
||||
📊 داشبورد
|
||||
</button>
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
||||
value={w.project_id}
|
||||
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
|
||||
title="انتقال به پروژه دیگر"
|
||||
>
|
||||
{data.lookup.projects.map((p2) => (
|
||||
<option key={p2.id} value={p2.id}>{p2.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
|
||||
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
|
||||
|
||||
export interface LookupData {
|
||||
categories: { id: string; slug: string; name: string }[];
|
||||
units: { id: number; code: string; name_fa: string | null }[];
|
||||
brands: { id: string; name: string }[];
|
||||
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||
materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
||||
}
|
||||
|
||||
@@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData {
|
||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
||||
const [lookup, setLookup] = useState<LookupData>({
|
||||
categories: [], units: [], brands: [], materials: [], projects: [],
|
||||
units: [], brands: [], materials: [], projects: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||
apiV26.lookup.categories(),
|
||||
const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||
apiV26.lookup.units(),
|
||||
apiV26.lookup.brands(),
|
||||
apiV26.lookup.materials(),
|
||||
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
|
||||
apiV26.inventory.list({ project_id: projectId }),
|
||||
apiV26.inventory.transactions({ project_id: projectId }),
|
||||
]);
|
||||
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
|
||||
setLookup({ units, brands, materials: mats, projects: projs });
|
||||
setWarehouses(whs);
|
||||
setInventory(inv);
|
||||
setTransactions(txs);
|
||||
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
|
||||
return m ? m.name : "—";
|
||||
}
|
||||
|
||||
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
|
||||
const c = categories.find((x) => x.id === id);
|
||||
return c ? c.name : "—";
|
||||
}
|
||||
|
||||
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
||||
const p = projects.find((x) => x.id === id);
|
||||
return p ? p.name : "—";
|
||||
|
||||
@@ -16,11 +16,9 @@ import {
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useAppStore } from "../../store";
|
||||
import {
|
||||
activeItems,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialLabel,
|
||||
refConverted,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
@@ -29,25 +27,45 @@ import { EmptyState } from "../../components/EmptyState";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
|
||||
return quotes.filter(
|
||||
(q) =>
|
||||
(q.projects || []).includes(name) ||
|
||||
q.project === name ||
|
||||
(q.line_items || []).some(
|
||||
(li) =>
|
||||
li.target_project?.name === name ||
|
||||
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// اقلام مربوط به این پروژه در یک quote
|
||||
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
||||
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
||||
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
||||
// برای این پروژه همچنان «پیشنهاد شده» است.
|
||||
function acceptedForProject(q: Quote, name: string): boolean {
|
||||
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||
}
|
||||
|
||||
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
||||
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
||||
function hasTargets(q: Quote): boolean {
|
||||
return (q.line_items || []).some((li) => !!li.target_project);
|
||||
}
|
||||
|
||||
// اقلام مربوط به این پروژه در یک quote:
|
||||
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
||||
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||
if (targeted.length) return targeted;
|
||||
if (items.some((li) => li.projects && li.projects.length)) {
|
||||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function qTotal(q: Quote): number {
|
||||
return activeItems(q).reduce((sum, li) => {
|
||||
const t = lineItemTotal(li);
|
||||
return sum + (t || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function daysSince(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
@@ -62,11 +80,11 @@ export function ProjectsPage() {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const { standardConversions } = useUnits();
|
||||
const addProject = useAddProject();
|
||||
const topics = useAppStore((s) => s.topics);
|
||||
|
||||
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics };
|
||||
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
||||
|
||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
@@ -147,10 +165,11 @@ export function ProjectsPage() {
|
||||
// ---------- پنل جزئیات یک پروژه ----------
|
||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
const quotes = projectQuotes(store.quotes, name);
|
||||
const sumBy = (st: Quote["status"]) =>
|
||||
quotes
|
||||
.filter((q) => q.status === st)
|
||||
.reduce((s, q) => s + qTotal(q), 0);
|
||||
|
||||
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
||||
const itemsTotal = (q: Quote) =>
|
||||
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||
|
||||
const matName = (mid: string) => {
|
||||
const m = materialById(store.materials, mid);
|
||||
@@ -169,26 +188,27 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
|
||||
const itemLine = (li: Quote["line_items"][number]) => {
|
||||
const total = lineItemTotal(li);
|
||||
const conv = refConverted(store, li);
|
||||
return (
|
||||
<div key={li.id}>
|
||||
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
||||
<b>{faNum(total || 0)} تومان</b>
|
||||
{conv && (
|
||||
<span className="pj-conv">
|
||||
{" "}
|
||||
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// دستهبندی ایزوله per-project:
|
||||
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
||||
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
||||
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
||||
const offered = quotes
|
||||
.filter((q) => q.status === "offered")
|
||||
.filter(
|
||||
(q) =>
|
||||
q.status === "offered" ||
|
||||
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
|
||||
)
|
||||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||||
const accepted = quotes.filter((q) => q.status === "accepted");
|
||||
const arrived = quotes.filter((q) => q.status === "arrived");
|
||||
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
|
||||
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
|
||||
|
||||
const offeredRow = (q: Quote) => {
|
||||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||||
@@ -199,7 +219,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
جمع: <b>{faNum(qTotal(q))} تومان</b>
|
||||
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||
</div>
|
||||
<div className="pj-side">
|
||||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||||
@@ -240,7 +260,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
هزینه: <b>{faNum(qTotal(q))} تومان</b>
|
||||
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||
</div>
|
||||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||||
</div>
|
||||
@@ -255,13 +275,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||||
ارزش باز: {faNum(sumBy("offered"))} تومان
|
||||
ارزش باز: {faNum(sumBy(offered))} تومان
|
||||
</span>
|
||||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||||
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
|
||||
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
|
||||
</span>
|
||||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||||
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
|
||||
ارزش رسیده: {faNum(sumBy(arrived))} تومان
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
|
||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { useAppStore } from "../../store";
|
||||
import { topicByName } from "../../lib/topics";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||
// (سلسلهمراتب دامنه: دسته → تأمینکننده؛ در v2.6 دسته = تاپیک)
|
||||
export function CategoryPage() {
|
||||
useStoreData();
|
||||
const { category: catParam = "" } = useParams();
|
||||
@@ -36,12 +36,29 @@ export function CategoryPage() {
|
||||
contact: "",
|
||||
});
|
||||
|
||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
||||
// همهی تأمینکنندهها برای انتخاب سریع
|
||||
const supplierOptions = useMemo(
|
||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||
[suppliers],
|
||||
);
|
||||
|
||||
// محاسبه میانگین زمان تحویل اختصاصی هر تأمینکننده بر اساس سفارشهای تحویلشده
|
||||
const supLeadTimes = useMemo(() => {
|
||||
const map = new Map<string, number | null>();
|
||||
suppliers.forEach((s) => {
|
||||
const supQuotes = quotes.filter(
|
||||
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||||
);
|
||||
const times = supQuotes
|
||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||
.filter((x): x is number => x != null);
|
||||
|
||||
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||
map.set(s.id, avg);
|
||||
});
|
||||
return map;
|
||||
}, [quotes, suppliers]);
|
||||
|
||||
if (!cat) return <Navigate to="/suppliers" replace />;
|
||||
|
||||
const sups = suppliersOfCategory(cat, cat, suppliers);
|
||||
@@ -69,7 +86,6 @@ export function CategoryPage() {
|
||||
toast.error("نام تأمینکننده را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
// تأمینکننده دقیقاً زیر همین دسته ثبت میشود (۱ دسته به ازای هر تأمینکننده)
|
||||
const s: Supplier = {
|
||||
id: nextId("s", suppliers),
|
||||
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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
|
||||
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { topicByName } from "../../lib/topics";
|
||||
import { useAppStore } from "../../store";
|
||||
import { api } from "../../lib/api";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { PurchaseHistory } from "./PurchaseHistory";
|
||||
|
||||
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||
function cleanPhoneForTel(phone?: string | null): string {
|
||||
if (!phone) return "";
|
||||
const enDigits = phone
|
||||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||
}
|
||||
|
||||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||||
export function SupplierDetailPage() {
|
||||
useStoreData();
|
||||
@@ -29,6 +39,19 @@ export function SupplierDetailPage() {
|
||||
const [linked, setLinked] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// محاسبه میانگین زمان تحویل این تأمینکننده بر اساس خریدهای تحویلشده
|
||||
const avgLeadTime = useMemo(() => {
|
||||
if (!s?.id) return null;
|
||||
const supQuotes = quotes.filter(
|
||||
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||||
);
|
||||
const times = supQuotes
|
||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||
.filter((x): x is number => x != null);
|
||||
|
||||
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||
}, [quotes, s?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
@@ -48,20 +71,8 @@ export function SupplierDetailPage() {
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// متریالهای خریداریشده از این تأمینکننده (از پیشنهادها)
|
||||
const purchased = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
quotes.forEach((q) => {
|
||||
if (q.supplier_id !== id) return;
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m || !m.category) return;
|
||||
out.add(m.category);
|
||||
});
|
||||
});
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [quotes, materials, id]);
|
||||
// دستهی این تأمینکننده — دسته فقط برچسبِ خودِ تأمینکننده است (v2.7)
|
||||
const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
|
||||
|
||||
if (!s) {
|
||||
return (
|
||||
@@ -80,7 +91,6 @@ export function SupplierDetailPage() {
|
||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||
|
||||
const saveCategories = (names: string[]) => {
|
||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
@@ -128,21 +138,51 @@ export function SupplierDetailPage() {
|
||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
||||
{avgLeadTime != null && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
|
||||
<Clock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span>{avgLeadTime} روز</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||||
<span>
|
||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
|
||||
</span>
|
||||
|
||||
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
|
||||
<div className="flex items-center">
|
||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
|
||||
<b className="me-1">تلفن:</b>
|
||||
{s.phone ? (
|
||||
<a
|
||||
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
|
||||
dir="ltr"
|
||||
title="تماس مستقیم با تأمینکننده"
|
||||
>
|
||||
<span className="tabular-nums">{s.phone}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span>
|
||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
|
||||
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||
@@ -163,7 +203,7 @@ export function SupplierDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
||||
{/* متریالهای این تأمینکننده */}
|
||||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<h4 className="mb-1 text-[13.5px] font-bold">
|
||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||
@@ -203,7 +243,7 @@ export function SupplierDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
||||
{/* سابقه خرید از این تأمینکننده */}
|
||||
<div className="mt-4 max-w-4xl">
|
||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
|
||||
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||
function cleanPhoneForTel(phone?: string | null): string {
|
||||
if (!phone) return "";
|
||||
const enDigits = phone
|
||||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||
}
|
||||
|
||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||
@@ -19,44 +19,77 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
|
||||
);
|
||||
}
|
||||
|
||||
// کارت تأمینکننده
|
||||
// کارت تأمینکننده همراه با برچسب میانگین زمان تحویل
|
||||
export function SupplierCard({
|
||||
s,
|
||||
topic,
|
||||
deletable,
|
||||
onDelete,
|
||||
materials = [],
|
||||
avgLeadTime,
|
||||
}: {
|
||||
s: Supplier;
|
||||
topic: string;
|
||||
deletable?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
||||
materials?: string[];
|
||||
avgLeadTime?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="sup-card">
|
||||
<div className="sc-head">
|
||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
||||
<div className="sc-head flex items-start justify-between gap-2">
|
||||
{/* فقط متن نام تأمینکننده لینک است و به کل کارت گسترش پیدا نمیکند */}
|
||||
<Link
|
||||
to={`/suppliers/${s.id}`}
|
||||
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{avgLeadTime != null && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
|
||||
title="میانگین زمان تحویل سفارشها"
|
||||
>
|
||||
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
|
||||
<span>{fmtNum(avgLeadTime)} روز</span>
|
||||
</span>
|
||||
)}
|
||||
{deletable && onDelete && (
|
||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sc-meta">
|
||||
<span>
|
||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||
</span>
|
||||
|
||||
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||
{s.phone ? (
|
||||
<a
|
||||
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
|
||||
title="تماس مستقیم با تأمینکننده"
|
||||
dir="ltr"
|
||||
>
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
|
||||
<span className="tabular-nums">{s.phone}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span>
|
||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{materials.length > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-1.5">
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
@@ -68,6 +101,7 @@ export function SupplierCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sc-actions">
|
||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||
<Plus className="h-3 w-3" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Link2,
|
||||
Pencil,
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import type { StandardConversion } from "../../lib/types";
|
||||
import { api, apiV26, nextId, type BrandLinkRow, type MaterialUnitRow } from "../../lib/api";
|
||||
import { api, apiV26, nextId, type BrandLinkRow, type MaterialSupplier, type MaterialUnitRow } from "../../lib/api";
|
||||
import {
|
||||
refreshProcurementStore,
|
||||
useDeleteStandardConversion,
|
||||
@@ -102,6 +104,12 @@ export function UnitsPage() {
|
||||
const [rowsLoading, setRowsLoading] = useState(false);
|
||||
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
|
||||
|
||||
// تأمینکنندههای متریال انتخابشده — داده از GET /materials/{id}/suppliers
|
||||
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
|
||||
const [suppliersLoading, setSuppliersLoading] = useState(false);
|
||||
// کلید پیشگیری از flicker: قبل از رسیدن پاسخ جدید، empty state نشان نمیدهیم
|
||||
const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null);
|
||||
|
||||
// واحدها و برندهای سراسری (فهرست دیتابیس)
|
||||
const [units, setUnits] = useState<UnitLite[]>([]);
|
||||
const [allBrands, setAllBrands] = useState<string[]>([]);
|
||||
@@ -253,6 +261,7 @@ export function UnitsPage() {
|
||||
if (!materialId) {
|
||||
setRows([]);
|
||||
setBrandLinks([]);
|
||||
setMaterialSuppliers([]);
|
||||
return;
|
||||
}
|
||||
void loadRows(materialId);
|
||||
@@ -263,6 +272,33 @@ export function UnitsPage() {
|
||||
setFormBrand("");
|
||||
}, [materialId, loadRows]);
|
||||
|
||||
// واکشی تأمینکنندههای متصل به متریال انتخابشده
|
||||
useEffect(() => {
|
||||
if (!materialId) {
|
||||
setMaterialSuppliers([]);
|
||||
setSuppliersLoadedId(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setSuppliersLoading(true);
|
||||
api.materials
|
||||
.listSuppliers(materialId)
|
||||
.then((list) => {
|
||||
if (alive) {
|
||||
setMaterialSuppliers(list);
|
||||
setSuppliersLoadedId(materialId);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!alive) return;
|
||||
setMaterialSuppliers([]);
|
||||
setSuppliersLoadedId(materialId);
|
||||
toast.error(err instanceof Error ? err.message : "خواندن تأمینکنندههای متریال ناموفق بود");
|
||||
})
|
||||
.finally(() => { if (alive) setSuppliersLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [materialId]);
|
||||
|
||||
const brandNameFor = (row: MaterialUnitRow): string => {
|
||||
if (!row.brand_material_id) return "";
|
||||
return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || "";
|
||||
@@ -449,7 +485,7 @@ export function UnitsPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="ضرایب تبدیل"
|
||||
title="اقلام و ضرایب"
|
||||
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
@@ -496,7 +532,7 @@ export function UnitsPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{g.base}</span>
|
||||
<span className="whitespace-normal break-words text-start">{g.base}</span>
|
||||
</span>
|
||||
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
||||
</button>
|
||||
@@ -519,7 +555,7 @@ export function UnitsPage() {
|
||||
)}
|
||||
onClick={() => setMaterialId(v.id)}
|
||||
>
|
||||
<span className="truncate">{v.label}</span>
|
||||
<span className="whitespace-normal break-words text-start">{v.label}</span>
|
||||
<span className="ut-ref">{v.refUnit || "—"}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -546,6 +582,77 @@ export function UnitsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── تامینکنندگان متریال ── */}
|
||||
<div className="mb-5 overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||
{/* سرصفحه — همیشه نمایش داده میشود */}
|
||||
<div className="border-b border-line px-4 py-3">
|
||||
<h4 className="text-[14px] font-bold text-gray-900">تامینکنندگان متریال</h4>
|
||||
</div>
|
||||
|
||||
{/* محتوا — min-h برای جلوگیری از layout shift */}
|
||||
<div className="min-h-[80px] p-4 transition-opacity duration-300 ease-in-out">
|
||||
{suppliersLoading || materialId !== suppliersLoadedId ? (
|
||||
/* اسپینر — نه empty state — تا پاسخ API برسد */
|
||||
<div className="flex h-[80px] items-center justify-center">
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent" />
|
||||
</div>
|
||||
) : materialSuppliers.length === 0 ? (
|
||||
<p className="flex h-[80px] items-center justify-center text-sm text-muted">
|
||||
تامینکنندهای برای این متریال ثبت نشده است
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 mt-4 max-h-[320px] overflow-y-auto">
|
||||
{materialSuppliers.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col md:flex-row md:items-center justify-between p-4 bg-white border border-gray-200 rounded-xl shadow-sm gap-4 w-full"
|
||||
>
|
||||
{/* نام تامینکننده */}
|
||||
<div className="flex-1 flex flex-col gap-1 text-right">
|
||||
<span className="text-xs font-medium text-gray-900">نام تامینکننده</span>
|
||||
<Link
|
||||
to={`/suppliers/${s.id}`}
|
||||
className="font-medium text-accent hover:underline decoration-dashed"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
</div>
|
||||
{/* نام مسئول */}
|
||||
<div className="flex-1 flex flex-col gap-1 text-right">
|
||||
<span className="text-xs font-medium text-gray-900">نام مسئول</span>
|
||||
<span className="text-gray-500">
|
||||
{s.contact_person || <span className="text-gray-500">ثبت نشده</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* تلفن */}
|
||||
<div className="flex-1 flex flex-col gap-1 text-right">
|
||||
<span className="text-xs font-medium text-gray-900">تلفن</span>
|
||||
{s.phone ? (
|
||||
<a
|
||||
href={`tel:${s.phone}`}
|
||||
dir="ltr"
|
||||
className="font-medium text-accent hover:underline decoration-dashed"
|
||||
>
|
||||
{s.phone}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-500">ثبت نشده</span>
|
||||
)}
|
||||
</div>
|
||||
{/* محل */}
|
||||
<div className="flex-1 flex flex-col gap-1 text-right">
|
||||
<span className="text-xs font-medium text-gray-900">محل</span>
|
||||
<span className="text-gray-500">
|
||||
{s.location || <span className="text-gray-500">ثبت نشده</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* واحد مرجع متریال */}
|
||||
<div className="ref-unit-box">
|
||||
<div className="ref-title">
|
||||
@@ -587,119 +694,6 @@ export function UnitsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ضرایب متریال */}
|
||||
<h4 className="sec-title">
|
||||
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
||||
</h4>
|
||||
{rowsLoading ? (
|
||||
<div className="conv-empty">در حال بارگذاری…</div>
|
||||
) : !rows.length ? (
|
||||
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div key={r.id}>
|
||||
<div className="cg-head">
|
||||
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
|
||||
</div>
|
||||
<div className="conv-row">
|
||||
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
|
||||
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
|
||||
<span className="cv-arrow">×</span>
|
||||
<span className="cv-unit">{ref}</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* فرم افزودن ضریب */}
|
||||
<div className="add-conv">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">
|
||||
{editingId ? (
|
||||
<>
|
||||
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
|
||||
<div className="field">
|
||||
<label>واحد پیشنهادشده</label>
|
||||
<Combobox
|
||||
value={formUnit}
|
||||
onChange={(it) => setFormUnit(it.label)}
|
||||
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="مثلاً حلقه"
|
||||
onInputChange={setFormUnit}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>ضریب</label>
|
||||
<NumberInput
|
||||
value={factorNum()}
|
||||
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
|
||||
placeholder="مثلاً 0.98"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند (خالی = همه برندها)</label>
|
||||
<Combobox
|
||||
value={formBrand}
|
||||
onChange={(it) => setFormBrand(it.label)}
|
||||
items={allBrands}
|
||||
allowAdd
|
||||
disabled={!!editingId}
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="مثلاً راجین"
|
||||
onInputChange={setFormBrand}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به واحد (مرجع)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
|
||||
value={ref || "مرجع"}
|
||||
placeholder="مرجع"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={() => void saveConv()}>
|
||||
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormUnit("");
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conv-live">{convLive}</div>
|
||||
</div>
|
||||
|
||||
{/* واحدهای ناشناخته */}
|
||||
{unknownUnits.length > 0 && (
|
||||
<>
|
||||
@@ -829,6 +823,121 @@ export function UnitsPage() {
|
||||
تبدیل ثابت برای همهی متریالها اعمال میشود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* ضرایب متریال */}
|
||||
<h4 className="sec-title">
|
||||
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
||||
</h4>
|
||||
{rowsLoading ? (
|
||||
<div className="conv-empty">در حال بارگذاری…</div>
|
||||
) : !rows.length ? (
|
||||
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div key={r.id}>
|
||||
<div className="cg-head">
|
||||
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
|
||||
</div>
|
||||
<div className="conv-row">
|
||||
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
|
||||
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
|
||||
<span className="cv-arrow">×</span>
|
||||
<span className="cv-unit">{ref}</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* فرم افزودن ضریب */}
|
||||
<div className="add-conv">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">
|
||||
{editingId ? (
|
||||
<>
|
||||
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
|
||||
<div className="field">
|
||||
<label>واحد پیشنهادشده</label>
|
||||
<Combobox
|
||||
value={formUnit}
|
||||
onChange={(it) => setFormUnit(it.label)}
|
||||
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="مثلاً حلقه"
|
||||
onInputChange={setFormUnit}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>ضریب</label>
|
||||
<NumberInput
|
||||
value={factorNum()}
|
||||
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
|
||||
placeholder="مثلاً 0.98"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند (خالی = همه برندها)</label>
|
||||
<Combobox
|
||||
value={formBrand}
|
||||
onChange={(it) => setFormBrand(it.label)}
|
||||
items={allBrands}
|
||||
allowAdd
|
||||
disabled={!!editingId}
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="مثلاً راجین"
|
||||
onInputChange={setFormBrand}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به واحد (مرجع)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
|
||||
value={ref || "مرجع"}
|
||||
placeholder="مرجع"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={() => void saveConv()}>
|
||||
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormUnit("");
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conv-live">{convLive}</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useSuppliers } from "../../hooks";
|
||||
import { useAppStore } from "../../store";
|
||||
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
|
||||
);
|
||||
}
|
||||
|
||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useMaterials } from "../../hooks";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
export interface ItemDraft {
|
||||
tempId: string;
|
||||
@@ -41,43 +43,17 @@ export function itemRowLabel(it: ItemDraft): string {
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
|
||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
||||
// نامِ پایه باقی بماند.
|
||||
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
||||
const fallback = (name || "").trim();
|
||||
if (!fallback) return "";
|
||||
let base = fallback;
|
||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||
const parts = [brand, category, ...specs]
|
||||
.map((p) => (p || "").trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const p of parts) {
|
||||
if (!base.includes(p)) continue;
|
||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||
if (!next) continue;
|
||||
base = next;
|
||||
}
|
||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||
if (parts.some((p) => p === base)) return fallback;
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
||||
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
topic: string,
|
||||
_topic: string,
|
||||
item: ItemDraft,
|
||||
field: "cat" | "brand" | "spec",
|
||||
field: "brand" | "spec",
|
||||
allItems: ItemDraft[] = [],
|
||||
apiBrands: string[] = [],
|
||||
apiSpecs: string[] = [],
|
||||
): string[] {
|
||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
||||
let list = materials;
|
||||
const cat = item.cat.trim();
|
||||
|
||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||
@@ -91,22 +67,18 @@ export function materialOptions(
|
||||
return [...out];
|
||||
};
|
||||
|
||||
if (field === "cat") {
|
||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (field === "brand") {
|
||||
// برند سراسری است — مستقل از تاپیک و دسته
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
||||
|
||||
if (cat)
|
||||
list = list.filter(
|
||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||
);
|
||||
|
||||
const base = new Set<string>();
|
||||
apiSpecs.forEach((s) => s && base.add(s));
|
||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||
@@ -115,7 +87,7 @@ export function materialOptions(
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
||||
// مرحله ۲ — مدیریت اقلام سفارش
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
@@ -134,7 +106,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
const allMaterialNames = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => {
|
||||
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
|
||||
const base = materialBaseName(m.name, m.brand, m.specs);
|
||||
if (base) set.add(base);
|
||||
});
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
@@ -192,50 +164,83 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
if (!it.specs.length) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||
}
|
||||
const dup = materials.some(
|
||||
(m) =>
|
||||
normalize(m.topic) === normalize(rec.topic) &&
|
||||
normalize(m.category) === normalize(rec.category) &&
|
||||
normalize(m.brand) === normalize(rec.brand) &&
|
||||
specsKey(m.specs) === specsKey(rec.specs),
|
||||
);
|
||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||
if (dup) return;
|
||||
onRemember(rec);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
||||
<div className="space-y-4">
|
||||
{/* هدر */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||
۲
|
||||
</span>
|
||||
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
<span>برند</span>
|
||||
<span>مشخصه</span>
|
||||
<span>تعداد</span>
|
||||
<span>واحد</span>
|
||||
<span>قیمت واحد</span>
|
||||
<span>قیمت کل</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
||||
{faNum(items.length)} قلم
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-field="line_items" className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||
{/* لیست کارتهای اقلام */}
|
||||
<div data-field="line_items" className="flex flex-col gap-3">
|
||||
{items.map((it, idx) => (
|
||||
<div
|
||||
key={it.tempId}
|
||||
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||
>
|
||||
{/* سربرگ قلم */}
|
||||
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
||||
{faNum(idx + 1)}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink truncate">
|
||||
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => remove(it.tempId)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">حذف</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ردیف ۱: متریال و برند */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="متریال"
|
||||
placeholder="نام متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
inputClassName="!text-xs !h-9 text-right"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||
<Combobox
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
@@ -245,34 +250,52 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
inputClassName="!text-xs !h-9 text-right"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۲: مشخصه فنی */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||
<MultiSelectCombobox
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||
placeholder="مشخصه"
|
||||
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* بخش مقادیر و محاسبات مالی */}
|
||||
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||
<NumberInput
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
showWords
|
||||
placeholder="مقدار"
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد"
|
||||
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
const name = v.trim();
|
||||
@@ -286,56 +309,60 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
inputClassName="!text-xs !h-9 text-right"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۴: قیمت هر واحد (راستچین شده) */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||
</label>
|
||||
<NumberInput
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
placeholder="قیمت هر واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۵: قیمت کل قلم (راستچین شده) */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||
<NumberInput
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
placeholder="قیمت کل قلم"
|
||||
showWords
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
|
||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => remove(it.tempId)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
||||
{/* دکمه افزودن قلم جدید */}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
||||
onClick={add}
|
||||
>
|
||||
<span className="text-base font-bold leading-none">+</span>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>افزودن قلم جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,40 +9,58 @@ export interface StepperProps {
|
||||
onGoto: (step: number) => void;
|
||||
}
|
||||
|
||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
||||
const STEPS = [
|
||||
{ id: 1, label: "تأمینکننده" },
|
||||
{ id: 2, label: "اقلام" },
|
||||
{ id: 3, label: "شرایط پرداخت" },
|
||||
{ id: 4, label: "پروژه" },
|
||||
{ id: 5, label: "بازبینی و ثبت" },
|
||||
];
|
||||
|
||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
||||
// نوار ۵ مرحلهای ویزارد — واکنشگرا و ضدشکستگی در موبایل
|
||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
||||
{STEPS.map((label, i) => {
|
||||
const step = i + 1;
|
||||
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||
{STEPS.map(({ id: step, label }) => {
|
||||
const isCurrent = step === current;
|
||||
const isComplete = completedSteps.has(step);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
||||
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||
isCurrent
|
||||
? "bg-[#2563eb] font-bold text-white"
|
||||
: isComplete
|
||||
? "bg-green-bg font-medium text-green"
|
||||
: "hover:bg-slate-bg",
|
||||
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
onClick={() => onGoto(step)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/20 text-white"
|
||||
: isComplete
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||
: "bg-muted/15 text-muted",
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
||||
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||
</span>
|
||||
|
||||
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||
!isCurrent && "hidden md:inline",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { topicByName } from "../../lib/topics";
|
||||
import { useAppStore } from "../../store";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { matchMaterial } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||
@@ -127,11 +127,9 @@ export function WizardPage() {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (_rec: MaterialRec) => {
|
||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||
};
|
||||
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
// ---------- پروژهها ----------
|
||||
const projectNames = useMemo(() => {
|
||||
const names = new Set<string>(projects.map((p) => p.name));
|
||||
quotes.forEach((q) => {
|
||||
@@ -159,7 +157,7 @@ export function WizardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- خلاصهی مرحله ۵ ----------
|
||||
// ---------- خلاصه مرحله ۵ ----------
|
||||
const review = useMemo(() => {
|
||||
let grandTotal = 0;
|
||||
items.forEach((it) => {
|
||||
@@ -202,23 +200,14 @@ export function WizardPage() {
|
||||
|
||||
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" };
|
||||
@@ -293,20 +282,13 @@ export function WizardPage() {
|
||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
looksLikeUuid(m.id) &&
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
}
|
||||
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛
|
||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
||||
if (!material && cat) {
|
||||
material = materials.find(
|
||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||
const matched = matchMaterial(
|
||||
materials.filter((m) => looksLikeUuid(m.id)),
|
||||
cat,
|
||||
brand,
|
||||
specs,
|
||||
);
|
||||
if (matched) material = matched;
|
||||
}
|
||||
let materialId: string;
|
||||
if (material && looksLikeUuid(material.id)) {
|
||||
@@ -317,7 +299,6 @@ export function WizardPage() {
|
||||
id: nextId("m", materials),
|
||||
name: wantName,
|
||||
topic: selectedTopic,
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit || "عدد",
|
||||
@@ -430,7 +411,7 @@ export function WizardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-screen pb-10">
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
@@ -438,10 +419,12 @@ export function WizardPage() {
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
{/* نوار مراحل استپر */}
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||
{currentStep === 1 && (
|
||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step1TopicSupplier
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
@@ -465,12 +448,12 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step3Termsheets
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
@@ -481,7 +464,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step4Projects
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
@@ -495,7 +478,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step5Review
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
@@ -510,15 +493,14 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
||||
{/* پاورقی کنترل مراحل */}
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||
<div>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep(currentStep - 1)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span>قبلی</span>
|
||||
@@ -526,21 +508,19 @@ export function WizardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
||||
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
|
||||
>
|
||||
انصراف
|
||||
</Link>
|
||||
|
||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
||||
{currentStep < TOTAL_STEPS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||
>
|
||||
<span>بعدی</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -551,7 +531,7 @@ export function WizardPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||
>
|
||||
<span>ذخیره پیشنهاد</span>
|
||||
<Check className="h-4 w-4" />
|
||||
@@ -560,21 +540,21 @@ export function WizardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||
{/* دیالوگ فیلدهای خالی */}
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[13.5px] leading-7">
|
||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
||||
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||
{emptyFields.map((f) => (
|
||||
<button
|
||||
key={f.step}
|
||||
type="button"
|
||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
||||
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(f.step);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
CategoryUnit,
|
||||
Material,
|
||||
Project,
|
||||
Quote,
|
||||
@@ -15,7 +14,6 @@ import type {
|
||||
Store,
|
||||
Supplier,
|
||||
Topic,
|
||||
UnitConversion,
|
||||
} from "../lib/types";
|
||||
|
||||
interface AppStore {
|
||||
@@ -24,8 +22,6 @@ interface AppStore {
|
||||
materials: Material[];
|
||||
projects: Project[];
|
||||
quotes: Quote[];
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
topics: Topic[]; // دستهبندیهای ۸گانه — از /material-categories
|
||||
|
||||
@@ -45,9 +41,6 @@ interface AppStore {
|
||||
deleteSupplier: (id: string) => void;
|
||||
addMaterial: (m: Material) => void;
|
||||
addProject: (p: Project) => void;
|
||||
setCategoryUnit: (cu: CategoryUnit) => void;
|
||||
setConversion: (c: UnitConversion) => void;
|
||||
deleteConversion: (id: string) => void;
|
||||
setStandardConversion: (sc: StandardConversion) => void;
|
||||
deleteStandardConversion: (id: string) => void;
|
||||
|
||||
@@ -62,8 +55,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
||||
materials: [],
|
||||
projects: [],
|
||||
quotes: [],
|
||||
categoryUnits: [],
|
||||
unitConversions: [],
|
||||
standardConversions: [],
|
||||
topics: [],
|
||||
|
||||
@@ -77,8 +68,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
||||
materials: store.materials,
|
||||
projects: store.projects,
|
||||
quotes: store.quotes,
|
||||
categoryUnits: store.categoryUnits,
|
||||
unitConversions: store.unitConversions,
|
||||
standardConversions: store.standardConversions,
|
||||
topics: store.topics,
|
||||
}),
|
||||
@@ -96,24 +85,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
||||
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
||||
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
||||
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
||||
setCategoryUnit: (cu) =>
|
||||
set((s) => {
|
||||
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
|
||||
const categoryUnits = [...s.categoryUnits];
|
||||
if (idx >= 0) categoryUnits[idx] = cu;
|
||||
else categoryUnits.push(cu);
|
||||
return { categoryUnits };
|
||||
}),
|
||||
setConversion: (c) =>
|
||||
set((s) => {
|
||||
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
|
||||
const unitConversions = [...s.unitConversions];
|
||||
if (idx >= 0) unitConversions[idx] = c;
|
||||
else unitConversions.push(c);
|
||||
return { unitConversions };
|
||||
}),
|
||||
deleteConversion: (id) =>
|
||||
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
|
||||
setStandardConversion: (sc) =>
|
||||
set((s) => {
|
||||
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
+2
-22
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
|
||||
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
||||
);
|
||||
|
||||
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
|
||||
-- category_id is NULLABLE and DERIVED automatically from the first linked
|
||||
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
|
||||
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
|
||||
-- Materials are global, category-free entities. name is GLOBALLY
|
||||
-- unique (uq_material_name). topic is a legacy display column no longer
|
||||
-- written by the app.
|
||||
CREATE TABLE materials (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
topic TEXT, -- LEGACY display column
|
||||
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||
@@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
|
||||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||
-- ============================================================================
|
||||
|
||||
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
|
||||
-- Idempotent: only touches rows where category_id IS NULL.
|
||||
CREATE OR REPLACE FUNCTION fn_derive_material_category()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE materials
|
||||
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
|
||||
updated_at = now()
|
||||
WHERE id = NEW.material_id
|
||||
AND category_id IS NULL;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
||||
AFTER INSERT ON supplier_materials
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
|
||||
|
||||
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
||||
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
||||
-- inserts decisions directly as 'chosen').
|
||||
|
||||
Reference in New Issue
Block a user