Author SHA1 Message Date
Behrad cff39542b0 Update ERP configuration and line item handling 2026-09-28 00:28:34 +03:30
11 changed files with 319 additions and 721 deletions
+93 -109
View File
@@ -1,4 +1,3 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO) # Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this Self-contained ship package: everything needed to run the stack is in this
@@ -13,22 +12,19 @@ folder — no external files or sibling folders required.
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik | | **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) | | **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
``` ```
Browser ──► Traefik :80 (edge) Browser ──► Traefik :80 (edge)
│ │
├── Host(localhost) ──► frontend (nginx SPA :80) ├── Host(localhost) ──► frontend (nginx SPA :80)
│ │ │ │
│ └── /api/* proxy ──► api :8000 (/api/v1) │ └── /api/* proxy ──► api :8000 (/api/v1)
│ │
├── Host(api.localhost) ──► api :8000 ├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login) ├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console) └── Host(s3/storage.localhost) ──► minio (API / console)
│ │
├── postgres :5432 (procurement + casdoor DBs) ├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files) └── minio :9000 (invoice/proforma files)
``` ```
--- ---
@@ -78,7 +74,6 @@ v1.3/
├── Projects/ (projects) ├── Projects/ (projects)
├── Units/ (unit conversions) ├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port) └── Inventory/ (warehouse management panel — React port)
``` ```
--- ---
@@ -91,12 +86,12 @@ the form (username / password) is sent to
(password grant), verifies the returned JWT, and starts the session — (password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor. 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 («کالاهای در راه»). 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, quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses). «کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only. The «انبارها» and «تغییر موجودی» sections are PM-only.
**Developer Mode** (top-left button on the login page) is a 3-step flow for **Developer Mode** (top-left button on the login page) is a 3-step flow for
the admin (super user): ① superuser auth gate (admin credentials only) → the admin (super user): ① superuser auth gate (admin credentials only) →
@@ -108,7 +103,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
for production builds). for production builds).
| What | Where | Credentials | | What | Where | Credentials |
| --- | --- | --- | |---|---|---|
| App | http://localhost (via Traefik) | — | | App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them | | Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) | | Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
@@ -116,11 +111,11 @@ for production builds).
Run `backend/tools/seed_casdoor.py` once after the first boot to create the Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details — Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist — addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md). are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a > The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see > single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data). > [Restoring the committed seed data](#restoring-the-committed-seed-data).
--- ---
@@ -130,13 +125,13 @@ The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS): at these public addresses (HTTPS):
| What | Address | | What | Address |
| --- | --- | |---|---|
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ | | **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ | | **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)). [`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel) ### MinIO console on the server (SSH tunnel)
@@ -145,20 +140,21 @@ The MinIO console is deliberately **loopback-only** on the server
is an SSH tunnel from your machine: is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you 1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel): use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
``` ```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.
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** 2. In your browser, open: **http://localhost:9001**
3. Log in with the production MinIO root credentials from the server's 3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are `/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See **never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md). [`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there. ERP stores, browsable and downloadable from there.
@@ -172,22 +168,22 @@ tunnel.
Notes from the server setup: 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. 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`. 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 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 with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default. tunnel remains the safer default.
--- ---
## Prerequisites (target machine) ## Prerequisites (target machine)
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS) - **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`) - ~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) - Internet access on first build (image + `npm ci` downloads)
--- ---
@@ -196,7 +192,6 @@ tunnel remains the safer default.
```powershell ```powershell
cd <this-folder> cd <this-folder>
docker compose up -d --build docker compose up -d --build
``` ```
First run downloads base images and builds the frontend (takes a few minutes). First run downloads base images and builds the frontend (takes a few minutes).
@@ -206,7 +201,6 @@ Wait until all services are up:
```powershell ```powershell
docker compose ps docker compose ps
``` ```
All should show `Up` (postgres also `(healthy)`). All should show `Up` (postgres also `(healthy)`).
@@ -216,7 +210,7 @@ All should show `Up` (postgres also `(healthy)`).
## Verify ## Verify
| What | Where | | What | Where |
| --- | --- | |---|---|
| Frontend UI | http://localhost | | Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs | | API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` | | API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
@@ -231,24 +225,23 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell ```powershell
backend\tools\smoke_test.py # read paths backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py backend\tools\smoke_test_write.py / smoke_test_misc.py
``` ```
> Every API route now requires a Casdoor JWT — a bare > Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design. > `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md). > Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
--- ---
## MinIO (object storage for invoice / proforma files) ## MinIO (object storage for invoice / proforma files)
| Item | Value | | Item | Value |
| --- | --- | |---|---|
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) | | S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
| Web console | http://localhost:9001 | | Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) | | Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) | | 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. File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -256,7 +249,6 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell ```powershell
.\mc.cmd ls --recursive .\mc.cmd ls --recursive
``` ```
--- ---
@@ -269,12 +261,10 @@ Port: 5433
Database: procurement Database: procurement
Username: postgres Username: postgres
Password: postgres Password: postgres
``` ```
```powershell ```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement docker exec -it procurement-postgres psql -U postgres -d procurement
``` ```
--- ---
@@ -286,28 +276,26 @@ database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up: 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 1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders: `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"
```
```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, 2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git: 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
``` ```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`
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor` first — this **replaces all data**). The scheduled backup sidecar
first — this **replaces all data**). The scheduled backup sidecar (see `docs/OPERATIONS.md`) automates this.
(see `docs/OPERATIONS.md`) automates this.
## Common operations ## Common operations
@@ -316,44 +304,40 @@ docker compose logs -f api # see logs
docker compose down # stop (keep data) docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
``` ```
--- ---
## Known behavior ## 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` every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback (401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless (`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production. `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); `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers. the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor + The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»). 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 at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z» stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote. 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 (`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`). 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, serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect): HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars). (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` (see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup. for the units-dashboard tables and `quote_attachments` at startup.
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction. - Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
* The supplier detail page (`/suppliers/:id`) shows the purchase history with - The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO. invoice/proforma download links from MinIO.
* **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 builds fail with disk-full errors: `docker system prune -a`, then rebuild. - If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not reachable; check `docker compose ps` and `docker compose logs api`.
reachable; check `docker compose ps` and `docker compose logs api`.
```
+27 -217
View File
@@ -2,12 +2,10 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog. GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink. DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — suppliers linked to a material. GET /materials/{id}/suppliers — price-comparison data source.
""" """
from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
@@ -22,51 +20,29 @@ from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
def link_to_dict( def link_to_dict(link: SupplierMaterial, material: Material | None = None,
link: SupplierMaterial, category: MaterialCategory | None = None,
material: Material | None = None, supplier: Supplier | None = None) -> dict:
category: MaterialCategory | None = None,
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 { return {
"id": str(link.id), "id": str(link.id),
"supplier_id": str(link.supplier_id), "supplier_id": str(link.supplier_id),
"material_id": str(link.material_id), "material_id": str(link.material_id),
"is_active": link.is_active, "is_active": link.is_active,
"last_quoted_price": ( "last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
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, "last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at), "created_at": to_iso(link.created_at),
"material": ( "material": {
{ "id": str(material.id),
"id": str(material.id), "name": material.name,
"name": material.name, "category": {"id": str(category.id), "name": category.name} if category else None,
"category": ( } if material else None,
{"id": str(category.id), "name": category.name} if category else None "supplier": {"id": str(supplier.id), "name": supplier.name} if supplier 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") @router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials( async def list_supplier_materials(
supplier_id: UUID, supplier_id,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -80,210 +56,44 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at) .order_by(SupplierMaterial.created_at)
) )
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = [lnk.material_id for lnk in items] mat_ids = {l.material_id for l in items}
materials: dict = {} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
if mat_ids: return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
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) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material( async def create_supplier_material(
supplier_id: UUID, supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
): ):
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id) material = await db.get(Material, payload.material_id)
return {"data": link_to_dict(link, material)} return {"data": link_to_dict(link, material, None)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material( async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
supplier_id: UUID,
material_id: UUID,
<<<<<<< Updated upstream
db: AsyncSession = Depends(get_db),
):
async with db.begin(): async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id) await unlink_supplier_material(db, supplier_id, material_id)
return { return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
"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( async def list_material_suppliers(
material_id: UUID, material_id,
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: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), 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) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
<<<<<<< Updated upstream
stmt = ( stmt = (
select(Supplier) select(SupplierMaterial)
.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) .where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last()) .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)
rows = (await db.execute(stmt)).all() 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()}
data = [ return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
{
"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
}
+27 -106
View File
@@ -6,8 +6,6 @@ services:
environment: environment:
POSTGRES_DB: procurement POSTGRES_DB: procurement
POSTGRES_USER: postgres POSTGRES_USER: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
@@ -15,7 +13,7 @@ services:
networks: networks:
- default - default
- shared # monitoring stack scrapes it (postgres-exporter) - shared
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
@@ -31,15 +29,7 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 1g mem_limit: 1g
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
# App is served on http://localhost (port 80) instead of :3000.
# Production: see docker-compose.prod.yml (real domains + HTTPS via
# Let's Encrypt, HTTP→HTTPS redirect).
# ==========================================================================
traefik: traefik:
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
# ("Error response from daemon" — Docker API version mismatch).
image: traefik:v3.6 image: traefik:v3.6
container_name: procurement-traefik container_name: procurement-traefik
restart: unless-stopped restart: unless-stopped
@@ -47,8 +37,6 @@ services:
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
# Containers are on several networks; Traefik must use the dedicated
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network - --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml - --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
@@ -62,8 +50,6 @@ services:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
# NOTE: mounted read-write — Docker Desktop rejects read-only
# /var/run/docker.sock mounts ("Error response from daemon").
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro - ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs - traefik_certs:/certs
@@ -81,24 +67,21 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports: ports:
- "9000:9000" # S3 API - "9000:9000"
- "9001:9001" # Web console - "9001:9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
labels: labels:
- traefik.enable=true - "traefik.enable=true"
# S3 API - "traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)"
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`) - "traefik.http.routers.minio-api.entrypoints=web"
- traefik.http.routers.minio-api.entrypoints=web - "traefik.http.services.minio-api-svc.loadbalancer.server.port=9000"
- traefik.http.routers.minio-api.service=minio-api-svc - "traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)"
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000 - "traefik.http.routers.minio-console.entrypoints=web"
# Web console - "traefik.http.routers.minio-console.service=minio-console-svc"
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`) - "traefik.http.services.minio-console-svc.loadbalancer.server.port=9001"
- traefik.http.routers.minio-console.entrypoints=web
- traefik.http.routers.minio-console.service=minio-console-svc
- traefik.http.services.minio-console-svc.loadbalancer.server.port=9001
networks: networks:
- traefik_network - traefik_network
@@ -107,39 +90,20 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 512m mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init: minio-init:
image: minio/mc:latest image: minio/mc:latest
container_name: procurement-minio-init container_name: procurement-minio-init
depends_on: depends_on:
minio: minio:
condition: service_started condition: service_started
environment: environment:
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files MINIO_BUCKET: procurement-files
entrypoint: ["/bin/sh", "-c", "until mc alias set local http://$${MINIO_ENDPOINT}$${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do echo 'waiting for minio server...'; sleep 2; done; mc mb --ignore-existing local/$${MINIO_BUCKET}; echo bucket ready;"]
entrypoint: >
/bin/sh -c "
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
echo 'waiting for minio server...'; sleep 2;
done;
mc mb --ignore-existing local/$${MINIO_BUCKET};
echo bucket $${MINIO_BUCKET} ready;
"
restart: "no" restart: "no"
# ==========================================================================
# Scheduled logical backups of BOTH databases (procurement + casdoor).
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
# ==========================================================================
db-backup: db-backup:
image: postgres:18 image: postgres:18
container_name: procurement-db-backup container_name: procurement-db-backup
@@ -152,33 +116,9 @@ services:
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14} BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes: volumes:
- ${BACKUP_DIR:-./backups}:/backups - ${BACKUP_DIR:-./backups}:/backups
entrypoint: > entrypoint: ["/bin/sh", "-c", "while true; do STAMP=$$(date +%Y%m%d-%H%M%S); pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump; pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump; find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; sleep $${BACKUP_INTERVAL_SECONDS}; done"]
/bin/sh -c "
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
while true; do
STAMP=$$(date +%Y%m%d-%H%M%S);
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
sleep $${BACKUP_INTERVAL_SECONDS};
done
"
restart: unless-stopped restart: unless-stopped
mem_limit: 256m mem_limit: 256m
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# is created by backend/tools/seed_casdoor.py — run it once after the
# first boot:
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
#
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
# created by sql/02-casdoor-db.sql on a fresh volume).
# ==========================================================================
casdoor: casdoor:
image: casbin/casdoor:latest image: casbin/casdoor:latest
container_name: procurement-casdoor container_name: procurement-casdoor
@@ -195,10 +135,10 @@ services:
- "8080:8000" - "8080:8000"
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`) - "traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)"
- traefik.http.routers.casdoor.entrypoints=web - "traefik.http.routers.casdoor.entrypoints=web"
- traefik.http.services.casdoor.loadbalancer.server.port=8000 - "traefik.http.services.casdoor.loadbalancer.server.port=8000"
networks: networks:
- traefik_network - traefik_network
@@ -220,14 +160,9 @@ services:
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false" PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev} PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly). PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173,http://192.168.1.104}
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080} PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
@@ -235,10 +170,8 @@ services:
PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123} PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text} PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-} PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-} PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
@@ -251,7 +184,7 @@ services:
networks: networks:
- traefik_network - traefik_network
- default - default
- shared # monitoring stack scrapes api:8000/metrics - shared
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""] test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
@@ -261,10 +194,10 @@ services:
start_period: 40s start_period: 40s
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`) - "traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)"
- traefik.http.routers.api.entrypoints=web - "traefik.http.routers.api.entrypoints=web"
- traefik.http.services.api.loadbalancer.server.port=8000 - "traefik.http.services.api.loadbalancer.server.port=8000"
depends_on: depends_on:
postgres: postgres:
@@ -279,27 +212,19 @@ services:
build: build:
context: ./frontend context: ./frontend
args: args:
# Developer Mode panel on the login page — dev-only aid. Production
# builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-} VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-} VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-} VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"] entrypoint: ["nginx", "-g", "daemon off;"]
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) - "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
- traefik.http.routers.frontend.entrypoints=web - "traefik.http.routers.frontend.entrypoints=web"
- traefik.http.services.frontend.loadbalancer.server.port=80 - "traefik.http.services.frontend.loadbalancer.server.port=80"
networks: networks:
- traefik_network - traefik_network
@@ -312,13 +237,9 @@ services:
mem_limit: 128m mem_limit: 128m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network: traefik_network:
name: traefik_network name: traefik_network
driver: bridge driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared: shared:
name: erp_shared name: erp_shared
driver: bridge driver: bridge
+4 -6
View File
@@ -8,14 +8,13 @@ export interface NumberInputProps {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
inputClassName?: string; inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین) align?: "right" | "center" | "left";
showWords?: boolean; // نمایش معادل فارسی زیر input showWords?: boolean;
inputMode?: "numeric" | "decimal" | undefined; inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار maxDecimals?: number;
disabled?: boolean; disabled?: boolean;
} }
// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی
export function NumberInput({ export function NumberInput({
value, value,
onChange, onChange,
@@ -28,7 +27,6 @@ export function NumberInput({
maxDecimals, maxDecimals,
disabled = false, disabled = false,
}: NumberInputProps) { }: NumberInputProps) {
// فرمت با رعایت حداکثر اعشار (گرد کردن)
const formatValue = (n: number): string => { const formatValue = (n: number): string => {
let v = n; let v = n;
if (maxDecimals != null) { if (maxDecimals != null) {
@@ -75,7 +73,7 @@ export function NumberInput({
type="text" type="text"
disabled={disabled} disabled={disabled}
className={cn( 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", "num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-xs font-normal text-ink shadow-xs transition-colors placeholder:text-xs placeholder:font-normal placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass, alignClass,
inputClassName, inputClassName,
)} )}
-37
View File
@@ -458,36 +458,6 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; 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 دانلود می‌آیند) // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment { interface RawAttachment {
id: string; id: string;
@@ -1156,13 +1126,6 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id }); 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: { projects: {
@@ -110,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" 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" /> <Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>اقلام و ضرایب</span> <span>ضرایب تبدیل</span>
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
+115 -224
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Building2,
ChevronDown, ChevronDown,
Link2, Link2,
Pencil, Pencil,
@@ -14,7 +12,7 @@ import {
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import type { StandardConversion } from "../../lib/types"; import type { StandardConversion } from "../../lib/types";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialSupplier, type MaterialUnitRow } from "../../lib/api"; import { api, apiV26, nextId, type BrandLinkRow, type MaterialUnitRow } from "../../lib/api";
import { import {
refreshProcurementStore, refreshProcurementStore,
useDeleteStandardConversion, useDeleteStandardConversion,
@@ -104,12 +102,6 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false); const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]); 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 [units, setUnits] = useState<UnitLite[]>([]);
const [allBrands, setAllBrands] = useState<string[]>([]); const [allBrands, setAllBrands] = useState<string[]>([]);
@@ -261,7 +253,6 @@ export function UnitsPage() {
if (!materialId) { if (!materialId) {
setRows([]); setRows([]);
setBrandLinks([]); setBrandLinks([]);
setMaterialSuppliers([]);
return; return;
} }
void loadRows(materialId); void loadRows(materialId);
@@ -272,33 +263,6 @@ export function UnitsPage() {
setFormBrand(""); setFormBrand("");
}, [materialId, loadRows]); }, [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 => { const brandNameFor = (row: MaterialUnitRow): string => {
if (!row.brand_material_id) return ""; if (!row.brand_material_id) return "";
return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || ""; return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || "";
@@ -485,7 +449,7 @@ export function UnitsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header <Header
title="اقلام و ضرایب" title="ضرایب تبدیل"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال" sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/" backTo="/"
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
@@ -582,77 +546,6 @@ export function UnitsPage() {
</div> </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-unit-box">
<div className="ref-title"> <div className="ref-title">
@@ -694,6 +587,119 @@ export function UnitsPage() {
</div> </div>
</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 && ( {unknownUnits.length > 0 && (
<> <>
@@ -823,121 +829,6 @@ export function UnitsPage() {
تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
</div> </div>
</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> </section>
+48 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus } from "lucide-react"; import { Trash2, Plus, Copy } from "lucide-react";
import type { Material } from "../../lib/types"; import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks"; import { useMaterials } from "../../hooks";
@@ -35,7 +35,6 @@ export interface Step2Props {
onRemember: (rec: MaterialRec) => void; onRemember: (rec: MaterialRec) => void;
} }
// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی)
export function itemRowLabel(it: ItemDraft): string { export function itemRowLabel(it: ItemDraft): string {
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean); const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)"; const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)";
@@ -43,7 +42,6 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
_topic: string, _topic: string,
@@ -87,7 +85,6 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa")); return [...base].sort((a, b) => a.localeCompare(b, "fa"));
} }
// مرحله ۲ — مدیریت اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials(); const materials = useMaterials();
@@ -151,6 +148,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
]); ]);
}; };
// قابلیت تکرار قلم در ردیف بعد با کپی عمیق فیلدها و شناسه یکتا
const duplicate = (index: number) => {
const target = items[index];
if (!target) return;
const clonedItem: ItemDraft = {
...target,
tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6),
specs: [...target.specs],
};
const nextItems = [...items];
nextItems.splice(index + 1, 0, clonedItem);
onItemsChange(nextItems);
};
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => { const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return; if (!selectedTopic) return;
let rec: MaterialRec; let rec: MaterialRec;
@@ -169,6 +182,8 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
onRemember(rec); onRemember(rec);
}; };
const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* هدر */} {/* هدر */}
@@ -208,17 +223,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""} {it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span> </span>
</div> </div>
{items.length > 1 && (
{/* اکشن‌های کارت: کپی و حذف */}
<div className="flex items-center gap-1">
<button <button
type="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" className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted hover:text-ink hover:bg-line/50 transition-colors"
title="حذف قلم" title="تکرار این قلم"
onClick={() => remove(it.tempId)} onClick={() => duplicate(idx)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span> <span className="text-[11px]">کپی</span>
</button> </button>
)}
{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> </div>
{/* ردیف ۱: متریال و برند */} {/* ردیف ۱: متریال و برند */}
@@ -234,7 +263,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="نام متریال" placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })} onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)} onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-xs !h-9 text-right" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div> </div>
@@ -250,7 +279,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="برند" placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })} onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)} onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-xs !h-9 text-right" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div> </div>
@@ -284,6 +313,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}} }}
placeholder="مقدار" placeholder="مقدار"
className="!text-right text-right" className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div> </div>
@@ -309,13 +339,13 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}) })
.catch(() => {}); .catch(() => {});
}} }}
inputClassName="!text-xs !h-9 text-right" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div> </div>
</div> </div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */} {/* ردیف ۴: قیمت هر واحد */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5"> <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 className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"} {it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
@@ -331,10 +361,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت هر واحد" placeholder="قیمت هر واحد"
showWords showWords
className="!text-right text-right" className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div> </div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */} {/* ردیف ۵: قیمت کل قلم */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5"> <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> <label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
@@ -349,6 +380,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت کل قلم" placeholder="قیمت کل قلم"
showWords showWords
className="!text-right text-right" className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div> </div>
</div> </div>
+2 -3
View File
@@ -1,11 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext", "target": "es2023",
"lib": ["ESNext", "DOM"], "lib": ["ES2023", "DOM"],
"module": "esnext", "module": "esnext",
"types": ["vite/client"], "types": ["vite/client"],
"strict": true,
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
View File
BIN
View File
Binary file not shown.