From 8c5f4ab770b5489fc5db54fe32bfdfb5239a7d1d Mon Sep 17 00:00:00 2001 From: fatemeh sane Date: Thu, 1 Oct 2026 11:16:46 +0330 Subject: [PATCH] feat: show material suppliers --- README.md | 202 +++++++------- backend/app/routers/supplier_materials.py | 158 ++++++++--- frontend/src/lib/api.ts | 43 +++ frontend/src/pages/Units/UnitsPage.tsx | 308 ++++++++++++++-------- frontend/tsconfig.app.json | 7 +- git | 0 6 files changed, 474 insertions(+), 244 deletions(-) create mode 100644 git diff --git a/README.md b/README.md index 09e9cf0..a7edde9 100644 --- a/README.md +++ b/README.md @@ -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,19 +13,22 @@ 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) - │ │ - │ └── /api/* proxy ──► api :8000 (/api/v1) - │ - ├── Host(api.localhost) ──► api :8000 - ├── Host(auth.localhost) ──► casdoor :8000 (OIDC login) - └── Host(s3/storage.localhost) ──► minio (API / console) - │ - ├── postgres :5432 (procurement + casdoor DBs) - └── minio :9000 (invoice/proforma files) +│ +├── Host(localhost) ──► frontend (nginx SPA :80) +│ │ +│ └── /api/* proxy ──► api :8000 (/api/v1) +│ +├── Host(api.localhost) ──► api :8000 +├── Host(auth.localhost) ──► casdoor :8000 (OIDC login) +└── Host(s3/storage.localhost) ──► minio (API / console) +│ +├── 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,12 +91,12 @@ 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 - stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»). -- **مدیر تدارکات / ادمین** → 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. +* **مدیر پروژه (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 +quotes, suppliers, projects, and in the inventory panel: dashboard, daily control, +«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses). +The «انبارها» and «تغییر موجودی» sections are PM-only. **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) → @@ -103,7 +108,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the for production builds). | What | Where | Credentials | -|---|---|---| +| --- | --- | --- | | 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 | | 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`) | @@ -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). --- @@ -125,13 +130,13 @@ The production deployment of this stack runs on the server and is reachable at these public addresses (HTTPS): | What | Address | -|---|---| +| --- | --- | | **App (ERP)** | https://aramesh.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 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) @@ -140,21 +145,20 @@ The MinIO console is deliberately **loopback-only** on the server is an SSH tunnel from your machine: 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 @ - ```powershell - ssh -L 9001:127.0.0.1:9001 @ - ``` +``` - 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** - 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). +`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are +**never** written in this repo or its history. See +[`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,22 +172,22 @@ tunnel. Notes from the server setup: -- 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 - why the console uses `9001`. -- 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. +* 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 +why the console uses `9001`. +* 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. --- ## 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 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)`). @@ -210,7 +216,7 @@ All should show `Up` (postgres also `(healthy)`). ## Verify | What | Where | -|---|---| +| --- | --- | | Frontend UI | http://localhost | | API docs (Swagger) | http://localhost:8000/docs | | API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` | @@ -225,23 +231,24 @@ 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). --- ## MinIO (object storage for invoice / proforma files) | Item | Value | -|---|---| +| --- | --- | | S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) | | 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 + ``` --- @@ -276,26 +286,28 @@ database dumps must never be committed (they contain user rows and signing 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: +`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, - 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` - first — this **replaces all data**). The scheduled backup sidecar - (see `docs/OPERATIONS.md`) automates this. + +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. ## Common operations @@ -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 - 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`, - `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 «کالاهای در راه»: - 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 - (`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 - 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 - (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/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 - reachable; check `docker compose ps` and `docker compose logs api`. +* 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`, +`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 «کالاهای در راه»: +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 +(`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 +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 +(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/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 the frontend shows «اتصال به سرور برقرار نشد» → the api container is not +reachable; check `docker compose ps` and `docker compose logs api`. + +``` diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 26e4026..52e812a 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -2,8 +2,10 @@ 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.ext.asyncio import AsyncSession @@ -20,29 +22,47 @@ from app.utils.serialization import to_iso router = APIRouter(tags=["supplier-materials"]) -def link_to_dict(link: SupplierMaterial, material: Material | None = None, - category: MaterialCategory | None = None, - supplier: Supplier | None = None) -> dict: +def link_to_dict( + link: SupplierMaterial, + material: Material | None = None, + category: MaterialCategory | None = None, + supplier: Supplier | None = None, +) -> dict: + """Serialize a SupplierMaterial ORM row to a plain dict for the API envelope.""" 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": { - "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, + "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 + ), } +# --------------------------------------------------------------------------- +# 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,44 +76,114 @@ 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()} - return {"data": [link_to_dict(l, materials.get(l.material_id), 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) - return {"data": link_to_dict(link, material, None)} + 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 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}} - - -@router.get("/materials/{material_id}/suppliers") -async def list_material_suppliers( - material_id, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), +async def delete_supplier_material( + supplier_id: UUID, + material_id: UUID, 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, + 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": "", + "name": "فولاد ناب آریا", + "phone": "021-..." | null, + "contact_person": "علی محمدی" | null, + "location": "تهران" | null + } + ] + } + """ material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") + stmt = ( - select(SupplierMaterial) + select(Supplier) + .join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id) .where(SupplierMaterial.material_id == material_id) - .order_by(SupplierMaterial.last_quoted_at.desc().nulls_last()) + .order_by(Supplier.name) ) - 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} + 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 + ] + } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e5f6ca..33c6fa8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -458,6 +458,39 @@ 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: clean supplier objects produced +// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name +// always comes from the suppliers table, never from the material row. +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, + // Bound straight from the supplier object in the API response — there is + // no fallback, so a supplier row can never render the material's name. + name: raw.name, + phone: raw.phone, + contact_person: raw.contact_person, + location: raw.location, + }; +} + // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند) interface RawAttachment { id: string; @@ -1126,6 +1159,16 @@ export const api = { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise => { await patchData(`/materials/${id}`, { canonical_unit_id }); }, + // فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers) + listSuppliers: async (materialId: string): Promise => { + // The backend returns { data: [...clean supplier objects] } from an + // atomic JOIN; getData unwraps the envelope. No fallback data — + // an empty array means the material genuinely has no suppliers. + const raw = await getData( + `/materials/${materialId}/suppliers`, + ); + return raw.map(adaptMaterialSupplier); + }, }, projects: { diff --git a/frontend/src/pages/Units/UnitsPage.tsx b/frontend/src/pages/Units/UnitsPage.tsx index e1c5717..03d32f5 100644 --- a/frontend/src/pages/Units/UnitsPage.tsx +++ b/frontend/src/pages/Units/UnitsPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { + Building2, ChevronDown, Link2, Pencil, @@ -12,7 +13,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 +103,11 @@ export function UnitsPage() { const [rowsLoading, setRowsLoading] = useState(false); const [brandLinks, setBrandLinks] = useState([]); + // تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts + const [materialSuppliers, setMaterialSuppliers] = useState([]); + const [suppliersLoading, setSuppliersLoading] = useState(false); + const [suppliersLoadedId, setSuppliersLoadedId] = useState(null); + // واحدها و برندهای سراسری (فهرست دیتابیس) const [units, setUnits] = useState([]); const [allBrands, setAllBrands] = useState([]); @@ -253,6 +259,7 @@ export function UnitsPage() { if (!materialId) { setRows([]); setBrandLinks([]); + setMaterialSuppliers([]); return; } void loadRows(materialId); @@ -263,6 +270,35 @@ 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); + // Surface the real error so backend issues are not silently hidden + const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود"; + toast.error(msg); + }) + .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 || ""; @@ -546,6 +582,48 @@ export function UnitsPage() { ) : ( <> + {/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */} +
+
+

+ تامین‌کنندگان متریال +

+
+
+ {suppliersLoading || materialId !== suppliersLoadedId ? ( +
+
+
+ ) : materialSuppliers.length === 0 ? ( +
+ تامین‌کننده‌ای برای این متریال ثبت نشده است +
+ ) : ( +
+ {materialSuppliers.map((s) => ( +
+ {/* نام تأمین‌کننده — مستقیم از پاسخ API، بدون fallback */} +
+ + {s.name} +
+ {(s.location || s.phone || s.contact_person) && ( +
+ {s.location && {s.location}} + {s.phone && {s.phone}} + {s.contact_person && {s.contact_person}} +
+ )} +
+ ))} +
+ )} +
+
+ {/* واحد مرجع متریال */}
@@ -587,119 +665,6 @@ export function UnitsPage() {
- {/* ضرایب متریال */} -

- ضریب‌های تبدیل -

- {rowsLoading ? ( -
در حال بارگذاری…
- ) : !rows.length ? ( -
هنوز ضریبی برای این متریال ثبت نشده است.
- ) : ( - rows.map((r) => ( -
-
- {unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref} -
-
- {brandNameFor(r) || "همه برندها"} - {faNum(r.conversion_factor)} - × - {ref} - - - - -
-
- )) - )} - - {/* فرم افزودن ضریب */} -
-

- {editingId ? ( - <> - ویرایش ضریب تبدیل - - ) : ( - <> - افزودن ضریب تبدیل - - )} -

-
-
- - setFormUnit(it.label)} - items={units.map((u) => unitDisplay(u)).filter(Boolean)} - allowAdd - addSuffix="به عنوان واحد جدید" - placeholder="مثلاً حلقه" - onInputChange={setFormUnit} - /> -
-
- - setFormFactor(n != null ? String(n) : "")} - placeholder="مثلاً 0.98" - inputMode="decimal" - /> -
-
- - setFormBrand(it.label)} - items={allBrands} - allowAdd - disabled={!!editingId} - addSuffix="به عنوان برند جدید" - placeholder="مثلاً راجین" - onInputChange={setFormBrand} - /> -
-
- - -
-
- - {editingId && ( - - )} -
-
-
{convLive}
-
- {/* واحدهای ناشناخته */} {unknownUnits.length > 0 && ( <> @@ -829,6 +794,121 @@ export function UnitsPage() { تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد + + + {/* ضرایب متریال */} +

+ ضریب‌های تبدیل +

+ {rowsLoading ? ( +
در حال بارگذاری…
+ ) : !rows.length ? ( +
هنوز ضریبی برای این متریال ثبت نشده است.
+ ) : ( + rows.map((r) => ( +
+
+ {unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref} +
+
+ {brandNameFor(r) || "همه برندها"} + {faNum(r.conversion_factor)} + × + {ref} + + + + +
+
+ )) + )} + + {/* فرم افزودن ضریب */} +
+

+ {editingId ? ( + <> + ویرایش ضریب تبدیل + + ) : ( + <> + افزودن ضریب تبدیل + + )} +

+
+
+ + setFormUnit(it.label)} + items={units.map((u) => unitDisplay(u)).filter(Boolean)} + allowAdd + addSuffix="به عنوان واحد جدید" + placeholder="مثلاً حلقه" + onInputChange={setFormUnit} + /> +
+
+ + setFormFactor(n != null ? String(n) : "")} + placeholder="مثلاً 0.98" + inputMode="decimal" + /> +
+
+ + setFormBrand(it.label)} + items={allBrands} + allowAdd + disabled={!!editingId} + addSuffix="به عنوان برند جدید" + placeholder="مثلاً راجین" + onInputChange={setFormBrand} + /> +
+
+ + +
+
+ + {editingId && ( + + )} +
+
+
{convLive}
+
+ )} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 6830b6f..6795847 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -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, @@ -23,4 +24,4 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"] -} +} \ No newline at end of file diff --git a/git b/git new file mode 100644 index 0000000..e69de29