Compare commits
42
Commits
b44816aa98
...
dashboard
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f93acec5 | ||
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a | ||
|
|
7bb432e715 | ||
|
|
b82c039265 | ||
|
|
d84637769a | ||
|
|
b0776da061 | ||
|
|
d38cf38bd9 | ||
|
|
a1220db844 | ||
|
|
1190ec42eb | ||
|
|
2747f9d4bb | ||
|
|
934b74ebbd | ||
|
|
dcc4d693f5 | ||
|
|
64bd292335 | ||
|
|
98c4b28815 | ||
|
|
aba9ba6486 | ||
|
|
965054093e | ||
|
|
3931baadd5 | ||
|
|
1bf55ba8a6 | ||
|
|
5458a2f99f | ||
|
|
df3ff4ea14 | ||
|
|
4b84844b54 | ||
|
|
ea6a377023 | ||
|
|
5400588751 | ||
|
|
0ea0322967 | ||
|
|
a84b33dde3 | ||
|
|
fbd34277c0 | ||
|
|
8c5f4ab770 | ||
|
|
af092fe4c8 | ||
|
|
e9fef5a104 | ||
|
|
4a3c226518 | ||
|
|
d85ede7d11 | ||
|
|
c7d860f431 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
da883a28c0 | ||
|
|
b6a1ce2ae7 | ||
|
|
64d80e15f8 |
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# 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
|
||||||
@@ -12,19 +13,22 @@ 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)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,6 +78,7 @@ v1.3/
|
|||||||
├── Projects/ (projects)
|
├── Projects/ (projects)
|
||||||
├── Units/ (unit conversions)
|
├── Units/ (unit conversions)
|
||||||
└── Inventory/ (warehouse management panel — React port)
|
└── 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 —
|
(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) →
|
||||||
@@ -103,7 +108,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`) |
|
||||||
@@ -111,11 +116,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`](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
|
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
> 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):
|
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`](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)
|
### 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:
|
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`](docs/SECRET-MANAGEMENT.md).
|
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
|
||||||
|
|
||||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
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.
|
||||||
@@ -168,22 +172,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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -192,6 +196,7 @@ Notes from the server setup:
|
|||||||
```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).
|
||||||
@@ -201,6 +206,7 @@ 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)`).
|
||||||
@@ -210,7 +216,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"}` |
|
||||||
@@ -225,23 +231,24 @@ 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`](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)
|
## 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.
|
||||||
|
|
||||||
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\mc.cmd ls --recursive
|
.\mc.cmd ls --recursive
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -261,10 +269,12 @@ 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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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:
|
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`
|
|
||||||
first — this **replaces all data**). The scheduled backup sidecar
|
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||||
(see `docs/OPERATIONS.md`) automates this.
|
first — this **replaces all data**). The scheduled backup sidecar
|
||||||
|
(see `docs/OPERATIONS.md`) automates this.
|
||||||
|
|
||||||
## Common operations
|
## Common operations
|
||||||
|
|
||||||
@@ -304,40 +316,44 @@ 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.
|
||||||
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
* **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 the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
* If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
||||||
reachable; check `docker compose ps` and `docker compose logs api`.
|
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||||
|
reachable; check `docker compose ps` and `docker compose logs api`.
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
+79
-11
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import AsyncSessionLocal
|
from app.db.engine import AsyncSessionLocal
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
@@ -106,6 +106,10 @@ class UserContext:
|
|||||||
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
||||||
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
||||||
display_name: str
|
display_name: str
|
||||||
|
# لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', 'inventory': ...}
|
||||||
|
panels: dict | None = None
|
||||||
|
# عضویت M:N در پروژهها — محدوده داده سطح limited (جدا از مسئولیت PM)
|
||||||
|
member_project_ids: list | None = None
|
||||||
|
|
||||||
|
|
||||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||||
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
if not casdoor_id:
|
if not casdoor_id:
|
||||||
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
||||||
|
|
||||||
|
username = str(claims.get("name") or claims.get("preferred_username") or "")
|
||||||
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(
|
user = User(
|
||||||
casdoor_id=casdoor_id,
|
casdoor_id=casdoor_id,
|
||||||
|
username=username or None,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
claims.get("firstName")
|
claims.get("firstName")
|
||||||
or claims.get("firstname")
|
or claims.get("firstname")
|
||||||
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
elif username and not user.username:
|
||||||
|
user.username = username
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
project_ids: list = []
|
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
||||||
if user.role == "pm":
|
panels: dict = {}
|
||||||
projects = (
|
panel_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for row in panel_rows:
|
||||||
|
panels[row.panel] = row.level
|
||||||
|
|
||||||
|
member_project_ids: list = [
|
||||||
|
row.project_id
|
||||||
|
for row in (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(UserProjectMembership)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(UserProjectMembership.user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(UserProjectMembership.created_at)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
project_ids = [p.id for p in projects]
|
]
|
||||||
|
|
||||||
|
pm_projects = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.pm_user_id == user.id)
|
||||||
|
.order_by(Project.created_at, Project.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
pm_project_ids_list = [p.id for p in pm_projects]
|
||||||
|
|
||||||
|
# ترکیب پروژههای تحت مدیریت مستقیم و پروژههای عضویت با حفظ اولویت و بدون تکرار
|
||||||
|
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
|
||||||
project_id = project_ids[0] if project_ids else None
|
project_id = project_ids[0] if project_ids else None
|
||||||
|
|
||||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||||
@@ -155,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
project_ids=project_ids,
|
project_ids=project_ids,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
|
panels=panels,
|
||||||
|
member_project_ids=member_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||||
"""Every project id managed by the given PM user (oldest first)."""
|
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||||
rows = (
|
pm_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project.id)
|
select(Project.id)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(Project.pm_user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return list(rows)
|
member_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership.project_id)
|
||||||
|
.where(UserProjectMembership.user_id == user.id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
|
||||||
|
|
||||||
|
|
||||||
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||||
@@ -217,3 +257,31 @@ def require_role(*roles: str):
|
|||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
return _dep
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def require_procurement_writer():
|
||||||
|
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
|
||||||
|
(دوره مهاجرت از مدل نقش به مدل پنل)."""
|
||||||
|
|
||||||
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
|
return ctx
|
||||||
|
if (ctx.panels or {}).get("procurement") == "full":
|
||||||
|
return ctx
|
||||||
|
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def allow_quote_create():
|
||||||
|
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
|
||||||
|
(محدود به پروژههای عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام میشود)."""
|
||||||
|
|
||||||
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
|
return ctx
|
||||||
|
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
|
||||||
|
return ctx
|
||||||
|
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|||||||
+164
-4
@@ -174,7 +174,7 @@ _V26_DDL = [
|
|||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
|
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
@@ -247,6 +247,7 @@ _V26_DDL = [
|
|||||||
physical_qty NUMERIC(18,6),
|
physical_qty NUMERIC(18,6),
|
||||||
transfer_pair_id UUID,
|
transfer_pair_id UUID,
|
||||||
performed_by TEXT,
|
performed_by TEXT,
|
||||||
|
delivered_to TEXT,
|
||||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -258,6 +259,9 @@ _V26_DDL = [
|
|||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
|
||||||
|
""",
|
||||||
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
||||||
"""
|
"""
|
||||||
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
||||||
@@ -380,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
|||||||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||||
c,
|
c,
|
||||||
)
|
)
|
||||||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
# v2.5/v2.6 category decoupling from materials: drop the old supplier-derived
|
||||||
|
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
|
||||||
|
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
|
||||||
|
# Category→Material filter cascade (see ensure_dashboard_schema below).
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||||
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||||
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
|
||||||
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
|
||||||
|
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||||
await run_statement("""
|
await run_statement("""
|
||||||
@@ -410,6 +415,50 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
|||||||
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_rbac_schema(engine: AsyncEngine) -> None:
|
||||||
|
"""Panel-access schema (pm branch): users.username, projects planning
|
||||||
|
columns, user_panels and user_project_members. Idempotent; safe on any
|
||||||
|
volume — the RBAC model expects these and login 500s without them."""
|
||||||
|
stmts = (
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE'",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_panels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
panel TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_user_panel UNIQUE (user_id, panel)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_user_panels_user_id ON user_panels(user_id)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_project_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_user_project_member UNIQUE (user_id, project_id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_user_project_members_user_id ON user_project_members(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_user_project_members_project_id ON user_project_members(project_id)",
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
for stmt in stmts:
|
||||||
|
await conn.execute(text(stmt))
|
||||||
|
logger.info("RBAC schema ensured (username / project columns / user_panels / user_project_members)")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||||
ddl = """
|
ddl = """
|
||||||
@@ -466,3 +515,114 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
|
|||||||
await conn.execute(text(stmt))
|
await conn.execute(text(stmt))
|
||||||
logger.info("bot_drafts table ensured")
|
logger.info("bot_drafts table ensured")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
|
||||||
|
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
|
||||||
|
ddl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS project_transfers (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
|
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||||||
|
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||||||
|
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||||
|
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||||||
|
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||||||
|
note TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
|
||||||
|
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
|
||||||
|
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
transferred_by TEXT,
|
||||||
|
received_at TIMESTAMPTZ,
|
||||||
|
received_by TEXT,
|
||||||
|
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
|
||||||
|
receipt_allocations JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
indexes = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
|
||||||
|
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_project_transfers_updated_at
|
||||||
|
BEFORE UPDATE ON project_transfers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
for stmt in indexes:
|
||||||
|
await conn.execute(text(stmt))
|
||||||
|
logger.info("project_transfers table ensured")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
|
||||||
|
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
|
||||||
|
# needs a real materials.category_id link. v2.6 had deliberately dropped the
|
||||||
|
# old supplier-derived column; this is a fresh, topic-backfilled link
|
||||||
|
# (nullable — a material may stay uncategorized). The rest is B-tree index
|
||||||
|
# coverage for the aggregation queries in services/dashboard_service.py.
|
||||||
|
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
|
||||||
|
# backfill. Every statement is idempotent.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DASHBOARD_SCHEMA_DDL = [
|
||||||
|
# Fresh column (inline FK names the constraint materials_category_id_fkey,
|
||||||
|
# matching the DO-block guard below for volumes that predate v2.8).
|
||||||
|
"""
|
||||||
|
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
|
||||||
|
""",
|
||||||
|
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
|
||||||
|
# groups those under «بدون دسته»).
|
||||||
|
"""
|
||||||
|
UPDATE materials SET category_id = mc.id
|
||||||
|
FROM material_categories mc
|
||||||
|
WHERE materials.topic = mc.name AND materials.category_id IS NULL
|
||||||
|
""",
|
||||||
|
# FK for the (rare) volume that already had the column without it.
|
||||||
|
"""
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
|
||||||
|
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
|
||||||
|
# Drill-down / PM scoping: every dashboard query filters through it.
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
|
||||||
|
# Daily flow + daily control (date-range aggregation).
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
|
||||||
|
# Type slices (withdrawal / return / waste) inside a date range.
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
|
||||||
|
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
|
||||||
|
# Funnel per-status counts (existing composite leads with supplier_id).
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
|
||||||
|
# Project-density workload grouping.
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
|
||||||
|
# Receipt-level arrival reporting.
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
|
||||||
|
# «What»-filtered stock scans across warehouses (existing composites lead
|
||||||
|
# with warehouse_id, useless for a bare material/brand filter).
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
|
||||||
|
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
|
||||||
|
|
||||||
|
Statement-per-transaction like ensure_v26: one failure can never block
|
||||||
|
the rest of the upgrade. Safe to call on every startup.
|
||||||
|
"""
|
||||||
|
for ddl in _DASHBOARD_SCHEMA_DDL:
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
except Exception: # pragma: no cover - pre-existing data conflicts
|
||||||
|
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
|
||||||
|
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
|
||||||
|
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ from app.db.init_db import (
|
|||||||
ensure_attachment_table,
|
ensure_attachment_table,
|
||||||
ensure_audit_table,
|
ensure_audit_table,
|
||||||
ensure_bot_drafts_table,
|
ensure_bot_drafts_table,
|
||||||
|
ensure_dashboard_schema,
|
||||||
ensure_drop_category_tables,
|
ensure_drop_category_tables,
|
||||||
ensure_dashboard_tables,
|
ensure_dashboard_tables,
|
||||||
ensure_material_topic_column,
|
ensure_material_topic_column,
|
||||||
ensure_quote_projects,
|
ensure_quote_projects,
|
||||||
|
ensure_rbac_schema,
|
||||||
|
ensure_project_transfers_table,
|
||||||
ensure_v26,
|
ensure_v26,
|
||||||
)
|
)
|
||||||
from app.observability import RequestContextMiddleware, setup_logging
|
from app.observability import RequestContextMiddleware, setup_logging
|
||||||
@@ -106,9 +109,12 @@ async def lifespan(_app: FastAPI):
|
|||||||
await ensure_material_topic_column(engine)
|
await ensure_material_topic_column(engine)
|
||||||
await ensure_quote_projects(engine)
|
await ensure_quote_projects(engine)
|
||||||
await ensure_v26(engine)
|
await ensure_v26(engine)
|
||||||
|
await ensure_rbac_schema(engine)
|
||||||
|
await ensure_dashboard_schema(engine)
|
||||||
await ensure_audit_table(engine)
|
await ensure_audit_table(engine)
|
||||||
await ensure_drop_category_tables(engine)
|
await ensure_drop_category_tables(engine)
|
||||||
await ensure_bot_drafts_table(engine)
|
await ensure_bot_drafts_table(engine)
|
||||||
|
await ensure_project_transfers_table(engine)
|
||||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from app.models.procurement import (
|
|||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.models.project_transfer import ProjectTransfer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"Unit",
|
"Unit",
|
||||||
@@ -60,4 +62,5 @@ __all__ = [
|
|||||||
"ItemDecision",
|
"ItemDecision",
|
||||||
"ItemReceipt",
|
"ItemReceipt",
|
||||||
"QuoteAttachment",
|
"QuoteAttachment",
|
||||||
|
"ProjectTransfer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
|
|||||||
|
|
||||||
class Material(Base):
|
class Material(Base):
|
||||||
__tablename__ = "materials"
|
__tablename__ = "materials"
|
||||||
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
# v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the
|
||||||
|
# category taxonomy for the dashboard filter cascade (nullable — backfill
|
||||||
|
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
|
||||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||||
__mapper_args__ = {"eager_defaults": True}
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("material_categories.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
canonical_unit_id: Mapped[int] = mapped_column(
|
canonical_unit_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||||
)
|
)
|
||||||
@@ -147,6 +152,7 @@ class Material(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||||
|
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||||
back_populates="material", lazy="raise", passive_deletes=True
|
back_populates="material", lazy="raise", passive_deletes=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
|
|||||||
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
||||||
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
||||||
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
||||||
|
delivered_to: Mapped[str | None] = mapped_column(Text)
|
||||||
transaction_date: Mapped[datetime] = mapped_column(
|
transaction_date: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.db.base import Base
|
|||||||
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
||||||
|
|
||||||
user_role_enum = Enum(
|
user_role_enum = Enum(
|
||||||
"pm", "procurement_manager", "admin",
|
"pm", "procurement_manager", "admin", "supervisor",
|
||||||
name="user_role", native_enum=True,
|
name="user_role", native_enum=True,
|
||||||
)
|
)
|
||||||
quote_status_enum = Enum(
|
quote_status_enum = Enum(
|
||||||
@@ -58,6 +58,7 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(Text)
|
||||||
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
phone: Mapped[str | None] = mapped_column(Text)
|
phone: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -65,6 +66,47 @@ class User(Base):
|
|||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanel(Base):
|
||||||
|
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
|
||||||
|
|
||||||
|
panel: 'procurement' | 'inventory' | 'pm'
|
||||||
|
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژهها
|
||||||
|
(بهجز تغییر موجودی)
|
||||||
|
'limited' — تدارکات: فقط افزودن استعلام برای پروژههای عضویت؛
|
||||||
|
انبار: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_panels"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
panel: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
level: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProjectMembership(Base):
|
||||||
|
"""user_project_members — عضویت M:N کاربر در پروژهها، جدا از مسئولیت
|
||||||
|
پروژه (projects.pm_user_id). محدودهی دادهی سطح limited را تعیین میکند."""
|
||||||
|
|
||||||
|
__tablename__ = "user_project_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
|
||||||
|
|
||||||
|
|
||||||
class Supplier(Base):
|
class Supplier(Base):
|
||||||
__tablename__ = "suppliers"
|
__tablename__ = "suppliers"
|
||||||
|
|
||||||
@@ -148,12 +190,23 @@ class Project(Base):
|
|||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
|
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
start_date: Mapped[date | None] = mapped_column(Date)
|
start_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
|
||||||
|
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
|
||||||
# v2.6: the single PM responsible for this project (confirms arrivals).
|
# v2.6: the single PM responsible for this project (confirms arrivals).
|
||||||
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="SET NULL")
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Quote(Base):
|
class Quote(Base):
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Numeric,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.catalog import _timestamptz, _uuid_pk
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransfer(Base):
|
||||||
|
"""project_transfers — Inter-project (inter-site) material transfer ledger.
|
||||||
|
|
||||||
|
Records the movement of materials from a source project warehouse to a
|
||||||
|
destination project. Stock is deducted from the source warehouse immediately
|
||||||
|
upon creation (status = 'in_transit') via out_transaction_id.
|
||||||
|
When the destination project PM confirms receipt in 'Arrivals', stock is
|
||||||
|
added to the destination warehouse (status = 'received').
|
||||||
|
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
|
||||||
|
Once 'received', the transfer is permanently locked from edit or cancellation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "project_transfers"
|
||||||
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
from_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
to_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
material_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
brand_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("brands.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
unit_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
|
||||||
|
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
|
||||||
|
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
|
||||||
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
|
||||||
|
|
||||||
|
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
transfer_date: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
transferred_by: Mapped[str | None] = mapped_column(Text)
|
||||||
|
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
received_by: Mapped[str | None] = mapped_column(Text)
|
||||||
|
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("warehouses.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
|
||||||
|
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
|
||||||
|
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
|
||||||
|
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
|
||||||
|
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
|
||||||
|
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
|
||||||
|
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
|
||||||
|
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
|
||||||
|
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from app.routers import (
|
|||||||
auth,
|
auth,
|
||||||
brand_materials,
|
brand_materials,
|
||||||
brands,
|
brands,
|
||||||
|
dashboard,
|
||||||
drafts,
|
drafts,
|
||||||
files,
|
files,
|
||||||
inventory,
|
inventory,
|
||||||
@@ -15,6 +16,7 @@ from app.routers import (
|
|||||||
material_categories,
|
material_categories,
|
||||||
materials,
|
materials,
|
||||||
pm,
|
pm,
|
||||||
|
project_transfers,
|
||||||
projects,
|
projects,
|
||||||
quote_events,
|
quote_events,
|
||||||
quotes,
|
quotes,
|
||||||
@@ -51,6 +53,8 @@ ROUTERS = [
|
|||||||
standard_conversions.router,
|
standard_conversions.router,
|
||||||
inventory.router,
|
inventory.router,
|
||||||
pm.router,
|
pm.router,
|
||||||
|
project_transfers.router,
|
||||||
|
dashboard.router,
|
||||||
users.router,
|
users.router,
|
||||||
drafts.router,
|
drafts.router,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||||
from app.models.procurement import LineItemSpec
|
from app.models.procurement import LineItemSpec
|
||||||
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
||||||
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_attribute_definition(
|
||||||
|
payload: AttributeDefinitionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
|||||||
|
|
||||||
@router.patch("/{definition_id}")
|
@router.patch("/{definition_id}")
|
||||||
async def update_attribute_definition(
|
async def update_attribute_definition(
|
||||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
definition_id,
|
||||||
|
payload: AttributeDefinitionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{definition_id}")
|
@router.delete("/{definition_id}")
|
||||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
async def delete_attribute_definition(
|
||||||
|
definition_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.config import settings
|
|||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.routers.users import _user_projects, user_to_dict
|
from app.routers.users import _user_projects, user_to_dict
|
||||||
from app.services import analytics
|
from app.services import analytics
|
||||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
|
||||||
from app.utils.ratelimit import limiter
|
from app.utils.ratelimit import limiter
|
||||||
|
|
||||||
logger = logging.getLogger("procurement.auth")
|
logger = logging.getLogger("procurement.auth")
|
||||||
@@ -78,6 +78,9 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
claims = await _verify_token(access_token)
|
claims = await _verify_token(access_token)
|
||||||
ctx = await context_from_claims(claims, db)
|
ctx = await context_from_claims(claims, db)
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
|
||||||
|
if ctx.role == "supervisor" and not has_projects:
|
||||||
|
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||||
await analytics.track(
|
await analytics.track(
|
||||||
"login_success",
|
"login_success",
|
||||||
{"username": payload.username, "role": ctx.role},
|
{"username": payload.username, "role": ctx.role},
|
||||||
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"expires_in": body.get("expires_in"),
|
"expires_in": body.get("expires_in"),
|
||||||
"user": user_to_dict(ctx.user, projects),
|
"user": await user_to_dict(ctx.user, projects, db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material
|
from app.models.catalog import Brand, BrandMaterial, Material
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||||
@@ -70,7 +72,11 @@ async def list_brand_materials(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand_material(
|
||||||
|
payload: BrandMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
if await db.get(Brand, payload.brand_id) is None:
|
if await db.get(Brand, payload.brand_id) is None:
|
||||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||||
if await db.get(Material, payload.material_id) is None:
|
if await db.get(Material, payload.material_id) is None:
|
||||||
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{bm_id}")
|
@router.patch("/{bm_id}")
|
||||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand_material(
|
||||||
|
bm_id,
|
||||||
|
payload: BrandMaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{bm_id}")
|
@router.delete("/{bm_id}")
|
||||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand_material(
|
||||||
|
bm_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial
|
from app.models.catalog import Brand, BrandMaterial
|
||||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def brand_to_dict(brand: Brand) -> dict:
|
def brand_to_dict(brand: Brand) -> dict:
|
||||||
@@ -42,7 +44,11 @@ async def list_brands(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand(
|
||||||
|
payload: BrandCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||||
if any(b.country == payload.country for b in rows):
|
if any(b.country == payload.country for b in rows):
|
||||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{brand_id}")
|
@router.patch("/{brand_id}")
|
||||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand(
|
||||||
|
brand_id,
|
||||||
|
payload: BrandUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{brand_id}")
|
@router.delete("/{brand_id}")
|
||||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand(
|
||||||
|
brand_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""MVP dashboard router — read-only aggregations for the warehouse +
|
||||||
|
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
|
||||||
|
client (frontend/src/lib/api.ts).
|
||||||
|
|
||||||
|
Scope (same resolution pattern as the inventory / pm routers):
|
||||||
|
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
|
||||||
|
an explicit ?project_id must be one of them.
|
||||||
|
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
|
||||||
|
an optional filter.
|
||||||
|
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
|
||||||
|
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
|
||||||
|
inventory router).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /dashboard/filters — «What» cascade options
|
||||||
|
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
|
||||||
|
- GET /dashboard/inventory/dead-stock — dormant stock table
|
||||||
|
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
|
||||||
|
- GET /dashboard/daily-control — today's tickets + performers
|
||||||
|
- GET /dashboard/procurement/workload — workload per project (density)
|
||||||
|
|
||||||
|
Every response is the standard `{"data": …}` envelope.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date as date_cls
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import UserContext, get_optional_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.db.engine import get_db
|
||||||
|
from app.services import dashboard_service
|
||||||
|
from app.utils.errors import (
|
||||||
|
ForbiddenError,
|
||||||
|
UnauthorizedError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardScope:
|
||||||
|
role: str # "pm" | "office"
|
||||||
|
project_ids: list | None # PM: managed project ids; office: None = all
|
||||||
|
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
|
||||||
|
|
||||||
|
|
||||||
|
async def dashboard_scope(
|
||||||
|
request: Request,
|
||||||
|
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژههای خودش)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> DashboardScope:
|
||||||
|
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
|
||||||
|
if request.headers.get("authorization"):
|
||||||
|
user: UserContext | None = await get_optional_user(request, db)
|
||||||
|
if user is not None:
|
||||||
|
if user.role is None:
|
||||||
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
|
if user.role == "pm":
|
||||||
|
allowed = list(user.project_ids or [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
|
||||||
|
if user.role in ("admin", "procurement_manager"):
|
||||||
|
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
||||||
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
|
||||||
|
|
||||||
|
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
|
||||||
|
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
||||||
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
|
if role == "procurement_manager" or role == "admin":
|
||||||
|
role = "office"
|
||||||
|
elif role == "pm":
|
||||||
|
role = "pm"
|
||||||
|
else:
|
||||||
|
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
|
||||||
|
if role == "pm":
|
||||||
|
raw = request.headers.get("x-project-id")
|
||||||
|
if not raw:
|
||||||
|
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
|
||||||
|
try:
|
||||||
|
if project_id is None:
|
||||||
|
project_id = uuid.UUID(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||||
|
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
|
||||||
|
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _what_filters(
|
||||||
|
material_id: str | None,
|
||||||
|
brand_id: uuid.UUID | None,
|
||||||
|
specs_hash: str | None,
|
||||||
|
spec_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""دامنهی «چه چیزی» را به پارامترهای سرویس تبدیل میکند.
|
||||||
|
|
||||||
|
شناسهی ماده اگر UUID معتبر باشد → دامنهی همان ماده (همهی مشخصات آن).
|
||||||
|
اگر مقدار ارسالی UUID نباشد (یعنی شناسهی مشخصه فرستاده شده) یا spec_id
|
||||||
|
صریح بیاید → دامنه به همان مشخصه محدود میشود (specs_hash).
|
||||||
|
"""
|
||||||
|
resolved_material: uuid.UUID | None = None
|
||||||
|
resolved_specs = specs_hash or spec_id
|
||||||
|
if material_id:
|
||||||
|
try:
|
||||||
|
resolved_material = uuid.UUID(str(material_id))
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
# شناسهی مشخصه (نه UUID ماده) — بهعنوان دامنهی مشخصه استفاده میشود
|
||||||
|
resolved_specs = resolved_specs or str(material_id)
|
||||||
|
return {
|
||||||
|
"material_id": resolved_material,
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"specs_hash": resolved_specs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/filters")
|
||||||
|
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Options for the global filter bar (Material→Brand→Specs)."""
|
||||||
|
return await dashboard_service.filter_options(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/drilldown")
|
||||||
|
async def inventory_drilldown(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
|
):
|
||||||
|
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
||||||
|
return await dashboard_service.inventory_drilldown(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/dead-stock")
|
||||||
|
async def dead_stock(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||||
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
|
):
|
||||||
|
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||||
|
return await dashboard_service.dead_stock(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
idle_days=idle_days,
|
||||||
|
limit=limit,
|
||||||
|
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/flow")
|
||||||
|
async def inventory_flow(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||||
|
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||||
|
warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همهی انبارها"),
|
||||||
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
|
):
|
||||||
|
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||||
|
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||||
|
date_from = date_from or (today - timedelta(days=29))
|
||||||
|
date_to = date_to or (today + timedelta(days=1))
|
||||||
|
if date_to <= date_from:
|
||||||
|
raise ValidationError("بازهی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
|
||||||
|
if (date_to - date_from).days > 180:
|
||||||
|
raise ValidationError("بازهی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
|
||||||
|
return await dashboard_service.daily_flow(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
|
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily-control")
|
||||||
|
async def daily_control(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیشفرض امروز)"),
|
||||||
|
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Daily control: today's outbound/return tickets + exact performer names."""
|
||||||
|
day = date or datetime.now(dashboard_service.TEHRAN).date()
|
||||||
|
return await dashboard_service.daily_control(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
day=day,
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/procurement/workload")
|
||||||
|
async def procurement_workload(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژهها."""
|
||||||
|
return await dashboard_service.procurement_workload(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
)
|
||||||
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
|
|||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||||
# برای هر کاربر واردشده آزاد است.
|
# برای هر کاربر واردشده آزاد است.
|
||||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||||
|
_attachment_writer = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||||
@@ -125,7 +126,7 @@ async def _attachment_or_404(
|
|||||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
|||||||
# Proforma — no status restriction
|
# Proforma — no status restriction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
@@ -58,6 +59,7 @@ class Scope:
|
|||||||
role: str
|
role: str
|
||||||
project_id: uuid.UUID | None
|
project_id: uuid.UUID | None
|
||||||
actor: str | None
|
actor: str | None
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def inventory_scope(
|
async def inventory_scope(
|
||||||
@@ -75,25 +77,41 @@ async def inventory_scope(
|
|||||||
if request.headers.get("authorization"):
|
if request.headers.get("authorization"):
|
||||||
user: UserContext = await get_optional_user(request, db)
|
user: UserContext = await get_optional_user(request, db)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if user.role is None:
|
panels = user.panels or {}
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
if user.role is None and not panels:
|
||||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
|
||||||
if user.role == "procurement_manager":
|
# ادمین: دستنخورده — همهچیز از جمله تغییر موجودی
|
||||||
|
if user.role == "admin":
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
if user.role not in ("pm", "admin"):
|
# لایه پنلها (جایگزین نقشها): پنل انبار اولویت دارد
|
||||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
if "inventory" in panels:
|
||||||
if user.role == "pm":
|
if panels["inventory"] == "full":
|
||||||
allowed = list(user.project_ids or [])
|
# کل سیستم و تمام پروژهها — بهجز تغییر موجودی
|
||||||
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
|
||||||
|
# limited: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||||
|
allowed = list(user.member_project_ids or [])
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("پروژهای به شما تخصیص داده نشده است")
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="pm", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||||
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||||
|
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||||
|
if user.role in ("procurement_manager", "pm"):
|
||||||
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
|
||||||
|
if user.role == "supervisor":
|
||||||
|
allowed = list(user.project_ids or [])
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ValidationError(
|
raise ForbiddenError("پروژه تخصیص داده نشده است")
|
||||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
if project_id is not None:
|
||||||
)
|
if project_id not in allowed:
|
||||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||||
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||||
|
if user.role == "admin":
|
||||||
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
|
raise ValidationError("نقش نامعتبر است", field="role")
|
||||||
|
|
||||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||||
@@ -128,9 +146,9 @@ async def inventory_scope(
|
|||||||
return Scope(role=role, project_id=project_id, actor=actor)
|
return Scope(role=role, project_id=project_id, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
def _require_pm(scope: Scope) -> None:
|
def _require_inventory_modifier(scope: Scope) -> None:
|
||||||
if scope.role != ROLE_PM:
|
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -197,6 +215,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
|
|||||||
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||||
"performed_by": tx.performed_by,
|
"performed_by": tx.performed_by,
|
||||||
|
"delivered_to": tx.delivered_to,
|
||||||
"transaction_date": to_iso(tx.transaction_date),
|
"transaction_date": to_iso(tx.transaction_date),
|
||||||
"note": tx.note,
|
"note": tx.note,
|
||||||
}
|
}
|
||||||
@@ -215,8 +234,16 @@ async def list_warehouses(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(Warehouse)
|
stmt = select(Warehouse)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
stmt = stmt.order_by(Warehouse.name)
|
stmt = stmt.order_by(Warehouse.name)
|
||||||
@@ -248,9 +275,11 @@ async def create_warehouse(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if payload.project_id not in allowed:
|
||||||
|
raise ValidationError("شما فقط میتوانید برای پروژه تخصیصیافته به خود انبار بسازید", field="project_id")
|
||||||
if await db.get(Project, payload.project_id) is None:
|
if await db.get(Project, payload.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||||
@@ -267,8 +296,9 @@ async def update_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if scope.project_id != wh.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
wh.name = payload.name
|
wh.name = payload.name
|
||||||
@@ -292,8 +322,10 @@ async def delete_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||||
# inventory cascade — block deletion if any stock history exists.
|
# inventory cascade — block deletion if any stock history exists.
|
||||||
linked = await db.scalar(
|
linked = await db.scalar(
|
||||||
@@ -323,10 +355,22 @@ async def list_inventory(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(InventoryItem)
|
stmt = select(InventoryItem)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
Warehouse.project_id == scope.project_id
|
if project_id is not None:
|
||||||
)
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == project_id
|
||||||
|
)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id.in_(allowed)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == scope.project_id
|
||||||
|
)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
Warehouse.project_id == project_id
|
Warehouse.project_id == project_id
|
||||||
@@ -388,8 +432,16 @@ async def list_transactions(
|
|||||||
stmt = select(InventoryTransaction).join(
|
stmt = select(InventoryTransaction).join(
|
||||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
@@ -397,9 +449,18 @@ async def list_transactions(
|
|||||||
if transaction_type is not None:
|
if transaction_type is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||||
|
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||||
|
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
|
|
||||||
@@ -454,8 +515,10 @@ async def inventory_history(
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise NotFoundError("موجودی یافت نشد")
|
raise NotFoundError("موجودی یافت نشد")
|
||||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||||
txs = (await db.execute(
|
txs = (await db.execute(
|
||||||
select(InventoryTransaction)
|
select(InventoryTransaction)
|
||||||
.where(InventoryTransaction.inventory_id == inventory_id)
|
.where(InventoryTransaction.inventory_id == inventory_id)
|
||||||
@@ -474,18 +537,20 @@ async def create_transaction(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_require_pm(scope)
|
_require_inventory_modifier(scope)
|
||||||
# No explicit db.begin(): the session may already be inside an implicit
|
# No explicit db.begin(): the session may already be inside an implicit
|
||||||
# transaction (inventory_scope read from it in the JWT path); get_db
|
# transaction (inventory_scope read from it in the JWT path); get_db
|
||||||
# commits on success / rolls back on failure.
|
# commits on success / rolls back on failure.
|
||||||
with nullcontext():
|
with nullcontext():
|
||||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||||
if wh.project_id != scope.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if payload.dest_warehouse_id is not None:
|
if wh.project_id not in allowed:
|
||||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if dest.project_id != scope.project_id:
|
if payload.dest_warehouse_id is not None:
|
||||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||||
|
if dest.project_id not in allowed:
|
||||||
|
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||||
await audit_record(
|
await audit_record(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||||
tags=["item-receipts"],
|
tags=["item-receipts"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
async def create_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await _decision_or_404(db, line_item_id)
|
decision = await _decision_or_404(db, line_item_id)
|
||||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||||
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("")
|
||||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||||
if decision is None:
|
if decision is None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||||
tags=["item-termsheets"],
|
tags=["item-termsheets"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_item_termsheet(
|
async def create_item_termsheet(
|
||||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ItemTermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
@@ -93,7 +99,12 @@ async def create_item_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{item_termsheet_id}")
|
@router.patch("/{item_termsheet_id}")
|
||||||
async def update_item_termsheet(
|
async def update_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
payload: ItemTermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
|||||||
|
|
||||||
@router.delete("/{item_termsheet_id}")
|
@router.delete("/{item_termsheet_id}")
|
||||||
async def delete_item_termsheet(
|
async def delete_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Unit
|
from app.models.catalog import AttributeDefinition, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||||
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
|
|||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
|
|||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("")
|
||||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
async def put_line_item_specs(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: SpecsPutIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, Unit
|
from app.models.catalog import Material, Unit
|
||||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||||
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
async def create_line_item(
|
||||||
|
quote_id,
|
||||||
|
payload: LineItemCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
|||||||
|
|
||||||
@router.patch("/{line_item_id}")
|
@router.patch("/{line_item_id}")
|
||||||
async def update_line_item(
|
async def update_line_item(
|
||||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: LineItemUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
@@ -131,7 +142,12 @@ async def update_line_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{line_item_id}")
|
@router.delete("/{line_item_id}")
|
||||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
async def delete_line_item(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Supplier
|
from app.models.procurement import Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def category_to_dict(category: MaterialCategory) -> dict:
|
def category_to_dict(category: MaterialCategory) -> dict:
|
||||||
@@ -46,7 +48,11 @@ async def list_categories(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
async def create_category(
|
||||||
|
payload: MaterialCategoryCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||||
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{category_id}")
|
@router.patch("/{category_id}")
|
||||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_category(
|
||||||
|
category_id,
|
||||||
|
payload: MaterialCategoryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{category_id}")
|
@router.delete("/{category_id}")
|
||||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
async def delete_category(
|
||||||
|
category_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import (
|
from app.models.catalog import (
|
||||||
AttributeDefinition,
|
AttributeDefinition,
|
||||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def _coerce_filter_value(data_type: str, raw: str):
|
def _coerce_filter_value(data_type: str, raw: str):
|
||||||
@@ -125,7 +127,10 @@ async def list_materials(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_material(
|
async def create_material(
|
||||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
payload: MaterialCreate,
|
||||||
|
expand: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
material = await material_service.create_material(db, payload)
|
material = await material_service.create_material(db, payload)
|
||||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||||
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{material_id}")
|
@router.patch("/{material_id}")
|
||||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_material(
|
||||||
|
material_id,
|
||||||
|
payload: MaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
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("متریال یافت نشد")
|
||||||
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}")
|
@router.delete("/{material_id}")
|
||||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material(
|
||||||
|
material_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
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("متریال یافت نشد")
|
||||||
@@ -248,7 +262,10 @@ async def list_material_units(
|
|||||||
|
|
||||||
@router.post("/{material_id}/units", status_code=201)
|
@router.post("/{material_id}/units", status_code=201)
|
||||||
async def create_material_unit(
|
async def create_material_unit(
|
||||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialUnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
await _material_or_404(db, material_id)
|
await _material_or_404(db, material_id)
|
||||||
@@ -287,7 +304,11 @@ async def create_material_unit(
|
|||||||
|
|
||||||
@router.patch("/{material_id}/units/{mu_id}")
|
@router.patch("/{material_id}/units/{mu_id}")
|
||||||
async def update_material_unit(
|
async def update_material_unit(
|
||||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
payload: MaterialUnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
@@ -319,7 +340,12 @@ async def update_material_unit(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}/units/{mu_id}")
|
@router.delete("/{material_id}/units/{mu_id}")
|
||||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material_unit(
|
||||||
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||||
)
|
)
|
||||||
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
|
|||||||
|
|
||||||
@router.put("/{material_id}/attribute-values")
|
@router.put("/{material_id}/attribute-values")
|
||||||
async def put_material_attribute_values(
|
async def put_material_attribute_values(
|
||||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialAttributeValuesPut,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
|
|||||||
+74
-21
@@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import BrandMaterial, Material, Unit
|
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
ItemReceipt,
|
ItemReceipt,
|
||||||
ItemReceiptAllocation,
|
ItemReceiptAllocation,
|
||||||
LineItem,
|
LineItem,
|
||||||
|
LineItemSpec,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
Supplier,
|
Supplier,
|
||||||
@@ -71,16 +72,17 @@ class ArrivalScope:
|
|||||||
user_id: uuid.UUID | None
|
user_id: uuid.UUID | None
|
||||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||||
display_name: str
|
display_name: str
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def arrivals_scope(
|
async def arrivals_scope(
|
||||||
request: Request,
|
request: Request,
|
||||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای دادههای پروژه)"),
|
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: دادههای پروژه)"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ArrivalScope:
|
) -> ArrivalScope:
|
||||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||||
|
|
||||||
PM → scoped to the requested project, which must be one they manage
|
PM / supervisor → scoped to the requested project, which must be one they manage
|
||||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||||
Admin / procurement manager → all projects (param = optional filter).
|
Admin / procurement manager → all projects (param = optional filter).
|
||||||
"""
|
"""
|
||||||
@@ -89,19 +91,19 @@ async def arrivals_scope(
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
if ctx.role is None:
|
if ctx.role is None:
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
if ctx.role == "pm":
|
if ctx.role in ("pm", "supervisor"):
|
||||||
allowed = list(ctx.project_ids or [])
|
allowed = list(ctx.project_ids or [])
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
|
||||||
if ctx.role in ("admin", "procurement_manager"):
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
|
||||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
|
||||||
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||||
# Disabled by default: an ungated header identity in production would
|
# Disabled by default: an ungated header identity in production would
|
||||||
# let anyone confirm receipts as PM.
|
# let anyone confirm receipts as PM.
|
||||||
@@ -110,8 +112,10 @@ async def arrivals_scope(
|
|||||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = "admin"
|
role = "admin"
|
||||||
|
elif role == "supervisor":
|
||||||
|
role = "pm"
|
||||||
if role not in ("pm", "admin"):
|
if role not in ("pm", "admin"):
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||||
# The explicit query param wins; otherwise the dev header carries it.
|
# The explicit query param wins; otherwise the dev header carries it.
|
||||||
if project_id is None and role == "pm":
|
if project_id is None and role == "pm":
|
||||||
raw_project = request.headers.get("x-project-id")
|
raw_project = request.headers.get("x-project-id")
|
||||||
@@ -179,20 +183,35 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
|||||||
async def pm_on_the_way(
|
async def pm_on_the_way(
|
||||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
|
||||||
):
|
):
|
||||||
stmt = (
|
stmt = (
|
||||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
|
||||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(Material, Material.id == LineItem.material_id)
|
.join(Material, Material.id == LineItem.material_id)
|
||||||
.join(Supplier, Supplier.id == Quote.supplier_id)
|
.join(Supplier, Supplier.id == Quote.supplier_id)
|
||||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
|
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
|
||||||
|
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
|
||||||
.where(ItemDecision.status == "chosen")
|
.where(ItemDecision.status == "chosen")
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if material_id is not None:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
stmt = stmt.where(Material.id == material_id)
|
||||||
if ctx.project_id is not None:
|
if brand_id is not None:
|
||||||
# PM: active project; admin/manager: optional project filter
|
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
|
||||||
|
if ctx.role == "pm":
|
||||||
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
if ctx.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||||
|
elif ctx.project_id is not None:
|
||||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||||
|
|
||||||
@@ -201,8 +220,29 @@ async def pm_on_the_way(
|
|||||||
# unit / category lookups for display
|
# unit / category lookups for display
|
||||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||||
|
# frozen spec snapshot per line item (key = attribute label)
|
||||||
|
li_ids = [d.LineItem.id for d in decisions]
|
||||||
|
specs_by_line: dict = {}
|
||||||
|
if li_ids:
|
||||||
|
spec_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec, AttributeDefinition)
|
||||||
|
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
|
||||||
|
.where(LineItemSpec.line_item_id.in_(li_ids))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for spec, ad in spec_rows:
|
||||||
|
value = spec.value_string
|
||||||
|
if value is None and spec.value_number is not None:
|
||||||
|
value = str(spec.value_number)
|
||||||
|
if value is None and spec.value_boolean is not None:
|
||||||
|
value = str(spec.value_boolean)
|
||||||
|
if value is None and spec.value_date is not None:
|
||||||
|
value = spec.value_date.isoformat()
|
||||||
|
if value is not None:
|
||||||
|
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m, sup, proj in decisions:
|
for d, li, q, m, sup, proj, bm, br in decisions:
|
||||||
received = received_map.get(d.id)
|
received = received_map.get(d.id)
|
||||||
out.append({
|
out.append({
|
||||||
"item_decision_id": str(d.id),
|
"item_decision_id": str(d.id),
|
||||||
@@ -214,6 +254,8 @@ async def pm_on_the_way(
|
|||||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name},
|
"material": {"id": str(m.id), "name": m.name},
|
||||||
|
"brand": {"id": str(br.id), "name": br.name} if br else None,
|
||||||
|
"specs": specs_by_line.get(li.id, {}),
|
||||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||||
"ordered_qty": str(li.quantity),
|
"ordered_qty": str(li.quantity),
|
||||||
@@ -243,8 +285,12 @@ async def pm_confirm_receipt(
|
|||||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||||
if decision is None or decision.status != "chosen":
|
if decision is None or decision.status != "chosen":
|
||||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if decision.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||||
|
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||||
|
|
||||||
li = await db.get(LineItem, decision.line_item_id)
|
li = await db.get(LineItem, decision.line_item_id)
|
||||||
quote = await db.get(Quote, li.quote_id)
|
quote = await db.get(Quote, li.quote_id)
|
||||||
@@ -451,10 +497,17 @@ async def pm_receipts(
|
|||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(Project, Project.id == ItemDecision.project_id)
|
.join(Project, Project.id == ItemDecision.project_id)
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
if ctx.project_id is not None:
|
if not allowed:
|
||||||
# PM: active project; admin/manager: optional project filter
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
if ctx.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||||
|
elif ctx.project_id is not None:
|
||||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||||
receipts = [r.ItemReceipt for r in rows]
|
receipts = [r.ItemReceipt for r in rows]
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.engine import get_db
|
||||||
|
from app.routers.pm import ArrivalScope, arrivals_scope
|
||||||
|
from app.schemas.project_transfers import (
|
||||||
|
ProjectTransferCreate,
|
||||||
|
ProjectTransferOut,
|
||||||
|
ProjectTransferReceive,
|
||||||
|
ProjectTransferUpdate,
|
||||||
|
)
|
||||||
|
from app.services import project_transfer_service
|
||||||
|
from app.utils.errors import ForbiddenError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["project-transfers"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_transfer_out(t) -> dict:
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"from_project": (
|
||||||
|
{"id": t.from_project.id, "name": t.from_project.name}
|
||||||
|
if t.from_project
|
||||||
|
else {"id": t.from_project_id, "name": ""}
|
||||||
|
),
|
||||||
|
"from_warehouse": (
|
||||||
|
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
|
||||||
|
if t.from_warehouse
|
||||||
|
else {"id": t.from_warehouse_id, "name": ""}
|
||||||
|
),
|
||||||
|
"to_project": (
|
||||||
|
{"id": t.to_project.id, "name": t.to_project.name}
|
||||||
|
if t.to_project
|
||||||
|
else {"id": t.to_project_id, "name": ""}
|
||||||
|
),
|
||||||
|
"to_warehouse": (
|
||||||
|
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
|
||||||
|
if t.to_warehouse
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"material": (
|
||||||
|
{"id": t.material.id, "name": t.material.name}
|
||||||
|
if t.material
|
||||||
|
else {"id": t.material_id, "name": ""}
|
||||||
|
),
|
||||||
|
"brand": (
|
||||||
|
{"id": t.brand.id, "name": t.brand.name}
|
||||||
|
if t.brand
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"unit": (
|
||||||
|
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
|
||||||
|
if t.unit
|
||||||
|
else {"id": t.unit_id, "code": "", "name_fa": None}
|
||||||
|
),
|
||||||
|
"quantity": t.quantity,
|
||||||
|
"specs": t.specs or {},
|
||||||
|
"specs_hash": t.specs_hash,
|
||||||
|
"note": t.note,
|
||||||
|
"status": t.status,
|
||||||
|
"transfer_date": t.transfer_date,
|
||||||
|
"transferred_by": t.transferred_by,
|
||||||
|
"received_at": t.received_at,
|
||||||
|
"received_by": t.received_by,
|
||||||
|
"receipt_allocations": t.receipt_allocations,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"updated_at": t.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /inventory/project-transfers — Create inter-project transfer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/inventory/project-transfers", status_code=201)
|
||||||
|
async def create_transfer(
|
||||||
|
payload: ProjectTransferCreate,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.create_project_transfer(
|
||||||
|
db=db,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
transferred_by=ctx.display_name,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /inventory/project-transfers — List transfers with direction and filters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/inventory/project-transfers")
|
||||||
|
async def list_transfers(
|
||||||
|
project_id: uuid.UUID | None = Query(default=None),
|
||||||
|
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
target_pid = project_id or ctx.project_id
|
||||||
|
transfers = await project_transfer_service.list_project_transfers(
|
||||||
|
db=db,
|
||||||
|
project_id=target_pid,
|
||||||
|
direction=direction,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /inventory/project-transfers/{id} — Single transfer details
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def get_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
|
||||||
|
if ctx.role == "pm" and ctx.project_id is not None:
|
||||||
|
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||||
|
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.patch("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def update_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferUpdate,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.update_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.delete("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def delete_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
res = await project_transfer_service.delete_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
)
|
||||||
|
return {"data": res}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/pm/transfers/on-the-way")
|
||||||
|
async def pm_incoming_transfers(
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if ctx.role == "pm" and ctx.project_id is None:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید")
|
||||||
|
|
||||||
|
transfers = await project_transfer_service.list_project_transfers(
|
||||||
|
db=db,
|
||||||
|
project_id=ctx.project_id,
|
||||||
|
direction="incoming",
|
||||||
|
status="in_transit",
|
||||||
|
)
|
||||||
|
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/inventory/project-transfers/{transfer_id}/receive")
|
||||||
|
async def receive_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferReceive,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.receive_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
received_by=ctx.display_name,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, Quote, User
|
from app.models.procurement import Project, Quote, User
|
||||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||||
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_date, to_iso
|
from app.utils.serialization import to_date, to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||||
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
d = {
|
d = {
|
||||||
"id": str(project.id),
|
"id": str(project.id),
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"location": project.location or "",
|
||||||
|
"project_manager": project.project_manager or "",
|
||||||
|
"description": project.description or "",
|
||||||
"start_date": to_date(project.start_date),
|
"start_date": to_date(project.start_date),
|
||||||
|
"status": project.status or "ACTIVE",
|
||||||
|
"progress": float(project.progress) if project.progress is not None else 0.0,
|
||||||
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
||||||
"created_at": to_iso(project.created_at),
|
"created_at": to_iso(project.created_at),
|
||||||
}
|
}
|
||||||
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
pm = await db.get(User, project.pm_user_id)
|
pm = await db.get(User, project.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif project.project_manager:
|
||||||
|
d["pm_name"] = project.project_manager
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +62,14 @@ async def list_projects(
|
|||||||
):
|
):
|
||||||
stmt = select(Project)
|
stmt = select(Project)
|
||||||
if q:
|
if q:
|
||||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Project.name.ilike(f"%{q}%"),
|
||||||
|
Project.description.ilike(f"%{q}%"),
|
||||||
|
Project.location.ilike(f"%{q}%"),
|
||||||
|
Project.project_manager.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, Project, sort)
|
stmt = sort_query(stmt, Project, sort)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
pm_map = await _pm_map(db, items)
|
pm_map = await _pm_map(db, items)
|
||||||
@@ -64,24 +78,38 @@ async def list_projects(
|
|||||||
d = {
|
d = {
|
||||||
"id": str(p.id),
|
"id": str(p.id),
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"description": p.description,
|
"location": p.location or "",
|
||||||
|
"project_manager": p.project_manager or "",
|
||||||
|
"description": p.description or "",
|
||||||
"start_date": to_date(p.start_date),
|
"start_date": to_date(p.start_date),
|
||||||
|
"status": p.status or "ACTIVE",
|
||||||
|
"progress": float(p.progress) if p.progress is not None else 0.0,
|
||||||
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
||||||
"created_at": to_iso(p.created_at),
|
"created_at": to_iso(p.created_at),
|
||||||
}
|
}
|
||||||
pm = pm_map.get(p.pm_user_id)
|
pm = pm_map.get(p.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif p.project_manager:
|
||||||
|
d["pm_name"] = p.project_manager
|
||||||
data.append(d)
|
data.append(d)
|
||||||
return {"data": data, "meta": meta}
|
return {"data": data, "meta": meta}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
async def create_project(
|
||||||
|
payload: ProjectCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = Project(
|
project = Project(
|
||||||
name=payload.name,
|
name=payload.name.strip(),
|
||||||
description=payload.description,
|
location=payload.location.strip() if payload.location else "",
|
||||||
|
project_manager=payload.project_manager.strip() if payload.project_manager else "",
|
||||||
|
description=payload.description.strip() if payload.description else "",
|
||||||
start_date=payload.start_date,
|
start_date=payload.start_date,
|
||||||
|
planned_start=payload.start_date,
|
||||||
|
status="ACTIVE",
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -103,37 +131,51 @@ async def update_project(
|
|||||||
request: Request,
|
request: Request,
|
||||||
ctx: UserContext | None = Depends(_optional_ctx),
|
ctx: UserContext | None = Depends(_optional_ctx),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
project.name = payload.name
|
project.name = payload.name.strip()
|
||||||
|
if payload.location is not None:
|
||||||
|
project.location = payload.location.strip()
|
||||||
|
if payload.project_manager is not None:
|
||||||
|
project.project_manager = payload.project_manager.strip()
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
project.description = payload.description
|
project.description = payload.description.strip()
|
||||||
if payload.start_date is not None:
|
if payload.start_date is not None:
|
||||||
project.start_date = payload.start_date
|
project.start_date = payload.start_date
|
||||||
|
project.planned_start = payload.start_date
|
||||||
|
if payload.status is not None:
|
||||||
|
project.status = payload.status
|
||||||
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
||||||
# Admin / procurement manager only (same rule as the Organization panel).
|
# Admin / procurement manager only (same rule as the Organization panel).
|
||||||
if "pm_user_id" in payload.model_fields_set:
|
if "pm_user_id" in payload.model_fields_set:
|
||||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role not in ("admin", "procurement_manager"):
|
if role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||||
if payload.pm_user_id is None:
|
if payload.pm_user_id is None:
|
||||||
project.pm_user_id = None
|
project.pm_user_id = None
|
||||||
else:
|
else:
|
||||||
pm_user = await db.get(User, payload.pm_user_id)
|
pm_user = await db.get(User, payload.pm_user_id)
|
||||||
if pm_user is None:
|
if pm_user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||||
if pm_user.role != "pm":
|
if pm_user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
|
||||||
project.pm_user_id = pm_user.id
|
project.pm_user_id = pm_user.id
|
||||||
|
if not project.project_manager:
|
||||||
|
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": await project_to_dict(db, project)}
|
return {"data": await project_to_dict(db, project)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
async def delete_project(
|
||||||
|
project_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote, QuoteEvent
|
from app.models.procurement import Quote, QuoteEvent
|
||||||
from app.schemas.procurement import NoteEventIn
|
from app.schemas.procurement import NoteEventIn
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -34,7 +36,12 @@ async def list_events(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
async def create_event(
|
||||||
|
quote_id,
|
||||||
|
payload: NoteEventIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
if quote is None:
|
if quote is None:
|
||||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import exists, func, or_, select
|
from sqlalchemy import exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material
|
from app.models.catalog import Material
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
@@ -36,12 +37,15 @@ from app.services.expand_service import (
|
|||||||
serialize_quote_with_children,
|
serialize_quote_with_children,
|
||||||
serialize_quotes,
|
serialize_quotes,
|
||||||
)
|
)
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||||
from app.utils.pagination import sort_query
|
from app.utils.pagination import sort_query
|
||||||
from app.utils.security import get_actor
|
from app.utils.security import get_actor
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||||
|
|
||||||
|
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
# --- list filter helpers ---------------------------------------------------
|
# --- list filter helpers ---------------------------------------------------
|
||||||
|
|
||||||
@@ -156,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def create_quote(
|
||||||
|
payload: QuoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
ctx: UserContext = Depends(allow_quote_create()),
|
||||||
|
_auth=None,
|
||||||
|
):
|
||||||
|
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||||
|
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||||
|
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||||
|
if ctx.project_id:
|
||||||
|
allowed.add(ctx.project_id)
|
||||||
|
allowed_str = {str(pid) for pid in allowed if pid}
|
||||||
|
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||||
|
if not wanted:
|
||||||
|
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||||
|
if not wanted <= allowed_str:
|
||||||
|
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||||
quote = await quote_service.create_quote(db, payload, actor)
|
quote = await quote_service.create_quote(db, payload, actor)
|
||||||
return {"data": await serialize_quote_with_children(db, quote)}
|
return {"data": await serialize_quote_with_children(db, quote)}
|
||||||
|
|
||||||
@@ -167,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
|||||||
|
|
||||||
@router.patch("/{quote_id}")
|
@router.patch("/{quote_id}")
|
||||||
async def update_quote(
|
async def update_quote(
|
||||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await serialize_quote(db, quote, set())}
|
return {"data": await serialize_quote(db, quote, set())}
|
||||||
@@ -175,7 +200,11 @@ async def update_quote(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/children")
|
@router.patch("/{quote_id}/children")
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteChildrenReplaceIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||||
@@ -188,7 +217,11 @@ async def replace_quote_children(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/accept")
|
@router.patch("/{quote_id}/accept")
|
||||||
async def accept_quote(
|
async def accept_quote(
|
||||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: AcceptQuoteIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await _accept_response(db, quote)}
|
return {"data": await _accept_response(db, quote)}
|
||||||
@@ -224,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/arrive")
|
@router.patch("/{quote_id}/arrive")
|
||||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def arrive_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -237,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/arrive-items")
|
@router.patch("/{quote_id}/arrive-items")
|
||||||
async def arrive_items(
|
async def arrive_items(
|
||||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: ArriveItemsIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||||
return {
|
return {
|
||||||
@@ -259,14 +301,23 @@ async def arrive_items(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/reject")
|
@router.patch("/{quote_id}/reject")
|
||||||
async def reject_quote(
|
async def reject_quote(
|
||||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: RejectQuoteIn | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/expire")
|
@router.patch("/{quote_id}/expire")
|
||||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def expire_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
@@ -276,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.delete("/{quote_id}")
|
@router.delete("/{quote_id}")
|
||||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
async def delete_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
result = await quote_service.delete_quote(db, quote_id)
|
result = await quote_service.delete_quote(db, quote_id)
|
||||||
return {"data": result}
|
return {"data": result}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||||
tags=["schedule-steps"],
|
tags=["schedule-steps"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||||
@@ -57,7 +59,11 @@ async def list_schedule_steps(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_schedule_step(
|
async def create_schedule_step(
|
||||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: ScheduleStepCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
step_no = payload.step_no
|
step_no = payload.step_no
|
||||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
|||||||
|
|
||||||
@router.patch("/{step_id}")
|
@router.patch("/{step_id}")
|
||||||
async def update_schedule_step(
|
async def update_schedule_step(
|
||||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
payload: ScheduleStepUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||||
@@ -109,7 +120,13 @@ async def update_schedule_step(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{step_id}")
|
@router.delete("/{step_id}")
|
||||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
async def delete_schedule_step(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
count = await db.scalar(
|
count = await db.scalar(
|
||||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import StandardConversion
|
from app.models.catalog import StandardConversion
|
||||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_standard_conversion(
|
||||||
|
payload: StandardConversionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
select(StandardConversion.id).where(
|
select(StandardConversion.id).where(
|
||||||
StandardConversion.from_unit == payload.from_unit,
|
StandardConversion.from_unit == payload.from_unit,
|
||||||
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{sc_id}")
|
@router.patch("/{sc_id}")
|
||||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
payload: StandardConversionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{sc_id}")
|
@router.delete("/{sc_id}")
|
||||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
async def delete_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
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 — price-comparison data source.
|
GET /materials/{id}/suppliers — suppliers linked to a material.
|
||||||
"""
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import Material, MaterialCategory
|
||||||
from app.models.procurement import Supplier, SupplierMaterial
|
from app.models.procurement import Supplier, SupplierMaterial
|
||||||
@@ -18,31 +21,50 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(tags=["supplier-materials"])
|
router = APIRouter(tags=["supplier-materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
def link_to_dict(
|
||||||
category: MaterialCategory | None = None,
|
link: SupplierMaterial,
|
||||||
supplier: Supplier | None = None) -> dict:
|
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 {
|
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": 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,
|
"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),
|
{
|
||||||
"name": material.name,
|
"id": str(material.id),
|
||||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
"name": material.name,
|
||||||
} if material else None,
|
"category": (
|
||||||
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
{"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")
|
@router.get("/suppliers/{supplier_id}/materials")
|
||||||
async def list_supplier_materials(
|
async def list_supplier_materials(
|
||||||
supplier_id,
|
supplier_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),
|
||||||
@@ -56,44 +78,110 @@ 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 = {l.material_id for l in items}
|
mat_ids = [lnk.material_id for lnk in items]
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials: dict = {}
|
||||||
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
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)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
async def create_supplier_material(
|
async def create_supplier_material(
|
||||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
supplier_id: UUID,
|
||||||
|
payload: SupplierMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
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, None)}
|
return {"data": link_to_dict(link, material)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier_material(
|
||||||
|
supplier_id: UUID,
|
||||||
|
material_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
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 {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/materials/{material_id}/suppliers")
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /materials/{material_id}/suppliers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/materials/{material_id}/suppliers",
|
||||||
|
summary="List all suppliers linked to a material",
|
||||||
|
)
|
||||||
async def list_material_suppliers(
|
async def list_material_suppliers(
|
||||||
material_id,
|
material_id: UUID,
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
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("متریال یافت نشد")
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(SupplierMaterial)
|
select(Supplier)
|
||||||
|
.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(Supplier.name)
|
||||||
)
|
)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
suppliers = (await db.execute(stmt)).scalars().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()}
|
return {
|
||||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
"data": [
|
||||||
|
{
|
||||||
|
"id": str(supplier.id),
|
||||||
|
"name": supplier.name,
|
||||||
|
"phone": supplier.phone,
|
||||||
|
"contact_person": supplier.contact_person,
|
||||||
|
"location": supplier.location,
|
||||||
|
}
|
||||||
|
for supplier in suppliers
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Quote, Supplier
|
from app.models.procurement import Quote, Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
async def create_supplier(
|
||||||
|
payload: SupplierCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await _category_or_404(db, payload.category_id)
|
category = await _category_or_404(db, payload.category_id)
|
||||||
supplier = Supplier(
|
supplier = Supplier(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{supplier_id}")
|
@router.patch("/{supplier_id}")
|
||||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_supplier(
|
||||||
|
supplier_id,
|
||||||
|
payload: SupplierUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{supplier_id}")
|
@router.delete("/{supplier_id}")
|
||||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier(
|
||||||
|
supplier_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
async def create_termsheet(
|
||||||
|
quote_id,
|
||||||
|
payload: TermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}")
|
@router.patch("/{termsheet_id}")
|
||||||
async def update_termsheet(
|
async def update_termsheet(
|
||||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
if ts.status != "offered":
|
if ts.status != "offered":
|
||||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}/status")
|
@router.patch("/{termsheet_id}/status")
|
||||||
async def update_termsheet_status(
|
async def update_termsheet_status(
|
||||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetStatusUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{termsheet_id}")
|
@router.delete("/{termsheet_id}")
|
||||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
async def delete_termsheet(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec
|
from app.models.procurement import LineItem, LineItemSpec
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/units", tags=["units"])
|
router = APIRouter(prefix="/units", tags=["units"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def unit_full_to_dict(unit: Unit) -> dict:
|
def unit_full_to_dict(unit: Unit) -> dict:
|
||||||
@@ -45,7 +47,11 @@ async def list_units(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
async def create_unit(
|
||||||
|
payload: UnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{unit_id}")
|
@router.patch("/{unit_id}")
|
||||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
payload: UnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}")
|
@router.delete("/{unit_id}")
|
||||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
async def delete_unit(
|
||||||
|
unit_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
|
|||||||
+186
-31
@@ -17,15 +17,21 @@ import uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, select, update, text, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
from app.auth import UserContext, get_current_user, require_role
|
||||||
from app.utils.audit import record as audit_record
|
from app.utils.audit import record as audit_record
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||||
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
from app.schemas.procurement import (
|
||||||
|
UserCreate,
|
||||||
|
UserPanelsUpdate,
|
||||||
|
UserPanelProjectsUpdate,
|
||||||
|
UserProjectUpdate,
|
||||||
|
UserRoleUpdate,
|
||||||
|
)
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ConflictError,
|
ConflictError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
|
|||||||
"pm": "مدیر پروژه",
|
"pm": "مدیر پروژه",
|
||||||
"procurement_manager": "مدیر تدارکات",
|
"procurement_manager": "مدیر تدارکات",
|
||||||
"admin": "ادمین",
|
"admin": "ادمین",
|
||||||
|
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||||
|
"supervisor": "سرپرست پروژه",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
PANEL_LEVELS = ("full", "limited")
|
||||||
|
PANELS = ("procurement", "inventory", "pm")
|
||||||
|
|
||||||
|
|
||||||
|
async def _panels_of(db: AsyncSession, user_id) -> dict:
|
||||||
|
"""panel -> level map for the user (لایه پنلها — جایگزین نقشها)."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return {row.panel: row.level for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
|
||||||
|
"""عضویت M:N کاربر در پروژهها (جدا از مسئولیت) — رشته."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership.project_id)
|
||||||
|
.where(UserProjectMembership.user_id == user_id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [str(pid) for pid in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
|
||||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||||
project_id/project_name stay as the first (oldest) one for compatibility."""
|
project_id/project_name stay as the first (oldest) one for compatibility.
|
||||||
|
با db، پنلهای تخصیصی و عضویت پروژهای هم برمیگردند."""
|
||||||
projects = projects or []
|
projects = projects or []
|
||||||
first = projects[0] if projects else None
|
first = projects[0] if projects else None
|
||||||
return {
|
out = {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"casdoor_id": user.casdoor_id,
|
"casdoor_id": user.casdoor_id,
|
||||||
|
"username": user.username,
|
||||||
"first_name": user.first_name,
|
"first_name": user.first_name,
|
||||||
"last_name": user.last_name,
|
"last_name": user.last_name,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
@@ -62,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
|||||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||||
"created_at": to_iso(user.created_at),
|
"created_at": to_iso(user.created_at),
|
||||||
}
|
}
|
||||||
|
if db is not None:
|
||||||
|
out["panels"] = await _panels_of(db, user.id)
|
||||||
|
member_pids = await _member_project_ids_of(db, user.id)
|
||||||
|
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
|
||||||
|
out["member_project_ids"] = all_pids
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
"""Admin OR procurement manager (the same person) — organization management."""
|
"""Admin, procurement manager, or project manager — organization management."""
|
||||||
if ctx.role not in ("admin", "procurement_manager"):
|
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
"""Every project assigned to the user (supervisor or PM) (oldest first).
|
||||||
if user.role != "pm":
|
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
|
||||||
|
"""
|
||||||
|
if user.role not in ("supervisor", "pm"):
|
||||||
return []
|
return []
|
||||||
|
member_subq = select(UserProjectMembership.project_id).where(
|
||||||
|
UserProjectMembership.user_id == user.id
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(
|
||||||
|
or_(
|
||||||
|
Project.pm_user_id == user.id,
|
||||||
|
Project.id.in_(member_subq),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
|
|||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
return {"data": user_to_dict(ctx.user, projects)}
|
return {"data": await user_to_dict(ctx.user, projects, db)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/projects")
|
@router.get("/me/projects")
|
||||||
@@ -138,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
|
|||||||
active project against this list and falls back gracefully when a
|
active project against this list and falls back gracefully when a
|
||||||
project is revoked."""
|
project is revoked."""
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
# عضویتهای M:N (لایه پنلها) هم به فهرست اضافه میشود
|
||||||
|
member_ids = set(ctx.member_project_ids or [])
|
||||||
|
have = {p.id for p in projects}
|
||||||
|
if member_ids:
|
||||||
|
extra = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.id.in_(member_ids))
|
||||||
|
.order_by(Project.created_at, Project.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
projects = list(projects) + [p for p in extra if p.id not in have]
|
||||||
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,20 +212,20 @@ async def list_users(
|
|||||||
):
|
):
|
||||||
"""Every user (with role + assigned projects) — Organization panel."""
|
"""Every user (with role + assigned projects) — Organization panel."""
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in projs:
|
for p in projs:
|
||||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||||
return {
|
return {
|
||||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||||
"meta": {"total": len(users)},
|
"meta": {"total": len(users)},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,30 +251,30 @@ async def create_user(
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": await user_to_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}")
|
@router.patch("/{user_id}")
|
||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
ctx: UserContext = Depends(require_role("admin")),
|
ctx: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
if payload.role == "admin" and ctx.role != "admin":
|
||||||
unassigns the user from their project."""
|
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
previous_role = user.role
|
previous_role = user.role
|
||||||
if user.role == "pm" and payload.role != "pm":
|
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
user.role = payload.role
|
user.role = payload.role
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
||||||
projects = await _user_projects(db, user)
|
projects = await _user_projects(db, user)
|
||||||
return {"data": user_to_dict(user, projects)}
|
return {"data": await user_to_dict(user, projects)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync")
|
@router.post("/sync")
|
||||||
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
|
|||||||
continue
|
continue
|
||||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
by_uname = await db.scalar(select(User).where(User.username == uname))
|
||||||
|
if by_uname is not None:
|
||||||
|
by_uname.casdoor_id = cid
|
||||||
|
continue
|
||||||
db.add(User(
|
db.add(User(
|
||||||
casdoor_id=cid,
|
casdoor_id=cid,
|
||||||
|
username=uname,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
cu.get("firstName")
|
cu.get("firstName")
|
||||||
or cu.get("displayName")
|
or cu.get("displayName")
|
||||||
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
|
|||||||
phone=str(cu.get("phone") or "") or None,
|
phone=str(cu.get("phone") or "") or None,
|
||||||
))
|
))
|
||||||
created += 1
|
created += 1
|
||||||
|
elif not existing.username:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
existing.username = uname
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in projs:
|
for p in projs:
|
||||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||||
return {
|
return {
|
||||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||||
"meta": {"total": len(users), "created": created},
|
"meta": {"total": len(users), "created": created},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,17 +357,97 @@ async def assign_user_project(
|
|||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
if user.role != "pm":
|
if user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||||
|
|
||||||
if payload.project_id is None:
|
if payload.project_id is None:
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
|
||||||
|
{"uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": await user_to_dict(user)}
|
||||||
|
|
||||||
project = await db.get(Project, payload.project_id)
|
project = await db.get(Project, payload.project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
project.pm_user_id = user.id
|
project.pm_user_id = user.id
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO project_assignments (id, user_id, project_id)
|
||||||
|
SELECT gen_random_uuid(), pu.id, :pid
|
||||||
|
FROM pm_users pu
|
||||||
|
WHERE pu.username = :uname
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project_assignments pa
|
||||||
|
WHERE pa.user_id = pu.id AND pa.project_id = :pid
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"pid": project.id, "uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
return {"data": await user_to_dict(user, await _user_projects(db, user))}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# پنلها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/panels")
|
||||||
|
@router.patch("/{user_id}/panels")
|
||||||
|
async def set_user_panels(
|
||||||
|
user_id,
|
||||||
|
payload: UserPanelsUpdate,
|
||||||
|
_: UserContext = Depends(_org_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
for panel, level in payload.panels.items():
|
||||||
|
if panel not in PANELS:
|
||||||
|
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
|
||||||
|
if level not in PANEL_LEVELS:
|
||||||
|
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
|
||||||
|
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
|
||||||
|
for panel, level in payload.panels.items():
|
||||||
|
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
|
||||||
|
await db.flush()
|
||||||
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/panel-projects")
|
||||||
|
@router.patch("/{user_id}/panel-projects")
|
||||||
|
async def set_user_panel_projects(
|
||||||
|
user_id,
|
||||||
|
payload: UserPanelProjectsUpdate,
|
||||||
|
_: UserContext = Depends(_org_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
wanted = list(dict.fromkeys(payload.project_ids))
|
||||||
|
if wanted:
|
||||||
|
found = (
|
||||||
|
await db.execute(select(Project).where(Project.id.in_(wanted)))
|
||||||
|
).scalars().all()
|
||||||
|
found_ids = {p.id for p in found}
|
||||||
|
unknown = [str(pid) for pid in wanted if pid not in found_ids]
|
||||||
|
if unknown:
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
for pid in wanted:
|
||||||
|
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
|
||||||
|
await db.flush()
|
||||||
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
specs: dict | None = None
|
specs: dict | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
delivered_to: str | None = None
|
||||||
transaction_date: datetime | None = None
|
transaction_date: datetime | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
|
|||||||
|
|
||||||
class ProjectCreate(APIModel):
|
class ProjectCreate(APIModel):
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(APIModel):
|
class ProjectUpdate(APIModel):
|
||||||
name: str | None = Field(default=None, min_length=1)
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
status: str | None = None
|
||||||
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
||||||
# Admin / procurement manager only; None unassigns this project's PM.
|
# Admin / procurement manager only; None unassigns this project's PM.
|
||||||
pm_user_id: UUID | None = None
|
pm_user_id: UUID | None = None
|
||||||
@@ -135,6 +140,11 @@ class AcceptTermsheetIn(APIModel):
|
|||||||
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAllocationIn(APIModel):
|
||||||
|
project_id: UUID
|
||||||
|
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
|
||||||
|
|
||||||
|
|
||||||
class ItemDecisionIn(APIModel):
|
class ItemDecisionIn(APIModel):
|
||||||
line_item_id: UUID
|
line_item_id: UUID
|
||||||
status: Literal["chosen", "rejected"]
|
status: Literal["chosen", "rejected"]
|
||||||
@@ -142,6 +152,7 @@ class ItemDecisionIn(APIModel):
|
|||||||
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
||||||
adjusted_notes: str | None = None
|
adjusted_notes: str | None = None
|
||||||
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
||||||
|
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
|
||||||
|
|
||||||
|
|
||||||
class ItemProjectsIn(APIModel):
|
class ItemProjectsIn(APIModel):
|
||||||
@@ -203,13 +214,13 @@ class UserCreate(APIModel):
|
|||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
casdoor_id: str | None = None
|
casdoor_id: str | None = None
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserRoleUpdate(APIModel):
|
class UserRoleUpdate(APIModel):
|
||||||
"""role=None revokes the role (and any project assignment)."""
|
"""role=None revokes the role (and any project assignment)."""
|
||||||
|
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserProjectUpdate(APIModel):
|
class UserProjectUpdate(APIModel):
|
||||||
@@ -341,3 +352,16 @@ class ReceiptUpdate(APIModel):
|
|||||||
received_qty: Decimal | None = Field(default=None, gt=0)
|
received_qty: Decimal | None = Field(default=None, gt=0)
|
||||||
on_time: bool | None = None
|
on_time: bool | None = None
|
||||||
quality_note: str | None = None
|
quality_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanelsUpdate(APIModel):
|
||||||
|
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
|
||||||
|
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف میشود."""
|
||||||
|
|
||||||
|
panels: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanelProjectsUpdate(APIModel):
|
||||||
|
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
|
||||||
|
|
||||||
|
project_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.schemas.common import APIModel
|
||||||
|
|
||||||
|
|
||||||
|
class WarehouseAllocationIn(APIModel):
|
||||||
|
warehouse_id: UUID
|
||||||
|
quantity: Decimal = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferCreate(APIModel):
|
||||||
|
from_warehouse_id: UUID
|
||||||
|
to_project_id: UUID
|
||||||
|
material_id: UUID
|
||||||
|
brand_id: UUID | None = None
|
||||||
|
unit_id: int
|
||||||
|
quantity: Decimal = Field(gt=0)
|
||||||
|
specs: dict = Field(default_factory=dict)
|
||||||
|
note: str | None = None
|
||||||
|
transfer_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferUpdate(APIModel):
|
||||||
|
to_project_id: UUID | None = None
|
||||||
|
quantity: Decimal | None = Field(default=None, gt=0)
|
||||||
|
note: str | None = None
|
||||||
|
transfer_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferReceive(APIModel):
|
||||||
|
allocations: list[WarehouseAllocationIn] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
|
||||||
|
)
|
||||||
|
received_at: datetime | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdNameOut(APIModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UnitBriefOut(APIModel):
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name_fa: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferOut(APIModel):
|
||||||
|
id: UUID
|
||||||
|
from_project: IdNameOut
|
||||||
|
from_warehouse: IdNameOut
|
||||||
|
to_project: IdNameOut
|
||||||
|
to_warehouse: IdNameOut | None = None
|
||||||
|
material: IdNameOut
|
||||||
|
brand: IdNameOut | None = None
|
||||||
|
unit: UnitBriefOut
|
||||||
|
quantity: Decimal
|
||||||
|
specs: dict
|
||||||
|
specs_hash: str
|
||||||
|
note: str | None = None
|
||||||
|
status: str
|
||||||
|
transfer_date: datetime
|
||||||
|
transferred_by: str | None = None
|
||||||
|
received_at: datetime | None = None
|
||||||
|
received_by: str | None = None
|
||||||
|
receipt_allocations: list[dict] | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
@@ -0,0 +1,800 @@
|
|||||||
|
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
|
||||||
|
|
||||||
|
Read-only service. Every function receives the resolved scope (PM → the list
|
||||||
|
of projects they manage; office roles → project_ids=None = whole
|
||||||
|
organization) plus an optional explicit project filter, and the optional
|
||||||
|
«What» filters (material_id / brand_id / specs_hash).
|
||||||
|
|
||||||
|
Quantities are NEVER mixed across units: inventory rows live in the
|
||||||
|
material's canonical unit, so stock aggregates carry their unit code and the
|
||||||
|
UI picks one unit at a time. Numeric results leave the service chart-ready
|
||||||
|
(floats); identifiers and labels are plain strings.
|
||||||
|
|
||||||
|
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
|
||||||
|
exactly — this module is the server side of that contract.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import date as date_cls
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from app.models.catalog import Brand, BrandMaterial, Material, Unit
|
||||||
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
|
from app.models.procurement import (
|
||||||
|
ItemDecision,
|
||||||
|
ItemReceipt,
|
||||||
|
LineItem,
|
||||||
|
Project,
|
||||||
|
Quote,
|
||||||
|
QuoteProjects,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
|
||||||
|
# daily widgets means today at this offset.
|
||||||
|
TEHRAN = timezone(timedelta(hours=3, minutes=30))
|
||||||
|
|
||||||
|
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
|
||||||
|
# separately so the UI can stack it as a distinct red segment inside the
|
||||||
|
# outbound bar. Transfers are excluded from the buckets (they are internal
|
||||||
|
# moves) but DO count toward the running balance.
|
||||||
|
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
|
||||||
|
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
|
||||||
|
"""«What» filters for a statement that already joins inventory→material."""
|
||||||
|
if material_id is not None:
|
||||||
|
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||||
|
if brand_id is not None:
|
||||||
|
stmt = stmt.where(InventoryItem.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_filters(stmt, *, project_ids, project_id):
|
||||||
|
"""warehouse.project_id restriction for the resolved dashboard scope."""
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _qty(value) -> float:
|
||||||
|
return round(float(value), 4) if value is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# «What» filter cascade — Category → Material → Brand → Specs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def filter_options(db: AsyncSession) -> dict:
|
||||||
|
"""Options for the global filter bar (Material → Brand → Specs).
|
||||||
|
|
||||||
|
Materials come from the catalog (active only). Brand options per material
|
||||||
|
are the UNION of the catalog links (brand_materials — every brand defined
|
||||||
|
for that material) and the distinct brands actually present in stock, so
|
||||||
|
the cascade is complete even for materials whose current stock rows carry
|
||||||
|
no brand yet. Spec options come from stock rows (inventory.specs_hash),
|
||||||
|
since specs are a stock-level dimension.
|
||||||
|
"""
|
||||||
|
materials = (
|
||||||
|
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
||||||
|
).scalars().all()
|
||||||
|
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
|
||||||
|
|
||||||
|
inv_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryItem.material_id,
|
||||||
|
InventoryItem.brand_id,
|
||||||
|
InventoryItem.specs_hash,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
)
|
||||||
|
.select_from(InventoryItem)
|
||||||
|
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
|
||||||
|
catalog_brand_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
||||||
|
.select_from(BrandMaterial)
|
||||||
|
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
||||||
|
.where(BrandMaterial.is_active.is_(True))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
brands_by_material: dict[uuid.UUID, dict] = {}
|
||||||
|
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
|
||||||
|
for material_id, brand_id, brand_name in catalog_brand_rows:
|
||||||
|
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
|
||||||
|
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
|
||||||
|
if brand_id is not None:
|
||||||
|
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
|
||||||
|
if specs_hash:
|
||||||
|
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
|
||||||
|
|
||||||
|
def material_entry(m: Material) -> dict:
|
||||||
|
unit = units.get(m.canonical_unit_id)
|
||||||
|
brands = brands_by_material.get(m.id, {})
|
||||||
|
spec_map = specs_by_material.get(m.id, {})
|
||||||
|
return {
|
||||||
|
"id": str(m.id),
|
||||||
|
"name": m.name,
|
||||||
|
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
|
||||||
|
"brands": [
|
||||||
|
{"id": str(bid), "name": bname}
|
||||||
|
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
|
||||||
|
],
|
||||||
|
"specs": [
|
||||||
|
{"hash": h, "specs": sp}
|
||||||
|
for h, sp in sorted(spec_map.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# فهرست تخت متریالها — سرآغاز آبشار سهفیلتری (متریال → برند → مشخصات)
|
||||||
|
return {"data": {"materials": [material_entry(m) for m in materials]}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 1 — Drill-down matrix (organization → project → warehouse)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def inventory_drilldown(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||||
|
|
||||||
|
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
|
||||||
|
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
|
||||||
|
warehouse, unit) stock aggregation. Active catalog materials without any
|
||||||
|
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
|
||||||
|
(labelled with their canonical unit) instead of disappearing in an inner
|
||||||
|
join.
|
||||||
|
"""
|
||||||
|
# تجمیع دفتر انبار بهازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
|
||||||
|
# راست LEFT JOIN؛ محدودیت دامنه (پروژههای مجاز) همینجا اعمال میشود تا
|
||||||
|
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
|
||||||
|
stock = (
|
||||||
|
select(
|
||||||
|
InventoryItem.material_id.label("material_id"),
|
||||||
|
InventoryItem.brand_id.label("brand_id"),
|
||||||
|
InventoryItem.specs_hash.label("specs_hash"),
|
||||||
|
InventoryItem.specs.label("specs"),
|
||||||
|
Project.id.label("pid"),
|
||||||
|
Project.name.label("pname"),
|
||||||
|
Warehouse.id.label("wid"),
|
||||||
|
Warehouse.name.label("wname"),
|
||||||
|
Unit.code.label("ucode"),
|
||||||
|
Unit.name_fa.label("uname_fa"),
|
||||||
|
func.sum(InventoryItem.quantity).label("qty"),
|
||||||
|
func.count().label("rows"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryItem)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
|
.join(Project, Project.id == Warehouse.project_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.group_by(
|
||||||
|
InventoryItem.material_id,
|
||||||
|
InventoryItem.brand_id,
|
||||||
|
InventoryItem.specs_hash,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Project.id,
|
||||||
|
Project.name,
|
||||||
|
Warehouse.id,
|
||||||
|
Warehouse.name,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
|
||||||
|
stock_sq = stock.subquery()
|
||||||
|
|
||||||
|
# واحد کاتالوگی ماده — برچسب واحدِ ردیفهای صفر («۰ عدد»)
|
||||||
|
canon_unit = aliased(Unit)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Material.id.label("mid"),
|
||||||
|
Material.name.label("mname"),
|
||||||
|
Brand.name.label("bname"),
|
||||||
|
stock_sq.c.specs,
|
||||||
|
stock_sq.c.pid,
|
||||||
|
stock_sq.c.pname,
|
||||||
|
stock_sq.c.wid,
|
||||||
|
stock_sq.c.wname,
|
||||||
|
stock_sq.c.ucode,
|
||||||
|
stock_sq.c.uname_fa,
|
||||||
|
func.coalesce(stock_sq.c.qty, 0).label("qty"),
|
||||||
|
func.coalesce(stock_sq.c.rows, 0).label("rows"),
|
||||||
|
canon_unit.code.label("canon_ucode"),
|
||||||
|
canon_unit.name_fa.label("canon_uname_fa"),
|
||||||
|
)
|
||||||
|
.select_from(Material)
|
||||||
|
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
|
||||||
|
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
|
||||||
|
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.status == "active")
|
||||||
|
)
|
||||||
|
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||||
|
if material_id is not None:
|
||||||
|
stmt = stmt.where(Material.id == material_id)
|
||||||
|
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||||
|
# موجودی روی محور میمانند (رفتار قبلی دستنخورده)
|
||||||
|
if brand_id is not None:
|
||||||
|
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
|
||||||
|
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
units_legend: dict[str, str] = {}
|
||||||
|
total_by_unit: dict[str, float] = {}
|
||||||
|
total_rows = 0
|
||||||
|
|
||||||
|
materials_tree: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
|
||||||
|
q = _qty(qty)
|
||||||
|
|
||||||
|
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||||
|
if ucode is not None:
|
||||||
|
units_legend[ucode] = uname_fa
|
||||||
|
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||||
|
elif canon_ucode is not None:
|
||||||
|
units_legend.setdefault(canon_ucode, canon_uname_fa)
|
||||||
|
total_by_unit.setdefault(canon_ucode, 0.0)
|
||||||
|
total_rows += row_count
|
||||||
|
|
||||||
|
# Build full material name
|
||||||
|
parts = [mname]
|
||||||
|
if bname:
|
||||||
|
parts.append(bname)
|
||||||
|
if specs:
|
||||||
|
parts.extend(str(v) for v in specs.values() if v)
|
||||||
|
full_name = " ".join(parts)
|
||||||
|
# Use full_name + unit as unique key for material level, or just full_name
|
||||||
|
mat_key = f"{mid}_{full_name}"
|
||||||
|
|
||||||
|
mat = materials_tree.setdefault(
|
||||||
|
mat_key,
|
||||||
|
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
||||||
|
)
|
||||||
|
if ucode is not None:
|
||||||
|
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
elif canon_ucode is not None:
|
||||||
|
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
|
||||||
|
mat["rows"] += row_count
|
||||||
|
|
||||||
|
# مواد بدون هیچ ردیف موجودی: فقط روی محور میمانند (بدون پروژه/انبار)
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
proj = mat["_projects"].setdefault(
|
||||||
|
pid,
|
||||||
|
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
||||||
|
)
|
||||||
|
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
proj["rows"] += row_count
|
||||||
|
|
||||||
|
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
|
||||||
|
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
wh["rows"] += row_count
|
||||||
|
|
||||||
|
materials = []
|
||||||
|
for mat in materials_tree.values():
|
||||||
|
projects_list = []
|
||||||
|
for proj in mat.pop("_projects").values():
|
||||||
|
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||||
|
projects_list.append({**proj, "warehouses": warehouses})
|
||||||
|
projects_list.sort(key=lambda p: p["name"])
|
||||||
|
materials.append({**mat, "projects": projects_list})
|
||||||
|
|
||||||
|
materials.sort(key=lambda m: m["name"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"units": units_legend,
|
||||||
|
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||||
|
"materials": materials,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 2 — Dead stock (dormant capital)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def dead_stock(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
idle_days: int = 60,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
|
||||||
|
|
||||||
|
Last activity = MAX(inventory_transactions.transaction_date) per row —
|
||||||
|
served by idx_inventory_tx_inventory_date — falling back to
|
||||||
|
inventory.updated_at for rows that never posted a transaction.
|
||||||
|
"""
|
||||||
|
last_tx = (
|
||||||
|
select(func.max(InventoryTransaction.transaction_date))
|
||||||
|
.where(InventoryTransaction.inventory_id == InventoryItem.id)
|
||||||
|
.correlate(InventoryItem)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
|
||||||
|
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
InventoryItem.id,
|
||||||
|
InventoryItem.quantity,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Material.name,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
Project.name,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
|
last_activity.label("last_activity"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryItem)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
|
.join(Project, Project.id == Warehouse.project_id)
|
||||||
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
|
||||||
|
.order_by(last_activity.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
stmt = _stock_filters(
|
||||||
|
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||||
|
)
|
||||||
|
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
now = datetime.now(TEHRAN)
|
||||||
|
items = []
|
||||||
|
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
|
||||||
|
items.append({
|
||||||
|
"inventory_id": str(inv_id),
|
||||||
|
"material": mat_name,
|
||||||
|
"brand": brand_name,
|
||||||
|
"specs": specs or {},
|
||||||
|
"warehouse": wh_name,
|
||||||
|
"project": proj_name,
|
||||||
|
"quantity": _qty(quantity),
|
||||||
|
"unit": {"code": ucode, "name_fa": uname_fa},
|
||||||
|
"last_activity": last.isoformat() if last else None,
|
||||||
|
"idle_days": (now - last).days if last else None,
|
||||||
|
})
|
||||||
|
return {"data": {"idle_days_threshold": idle_days, "items": items}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def daily_flow(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
date_from: datetime,
|
||||||
|
date_to: datetime,
|
||||||
|
warehouse_id=None,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Per-day buckets from the signed ledger.
|
||||||
|
|
||||||
|
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
|
||||||
|
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
||||||
|
balance = running sum of EVERY signed movement (transfers/found/stock
|
||||||
|
counts included), seeded with the opening balance before `date_from`.
|
||||||
|
|
||||||
|
Each day also carries a `breakdown` list — the ledger rows behind that
|
||||||
|
day's aggregates, grouped by (category, transaction type, specs, brand,
|
||||||
|
warehouse) — so the chart can subdivide categories into tonal shades and
|
||||||
|
the tooltip can itemize them. When a single material is selected, the
|
||||||
|
payload also carries that material's canonical `unit`.
|
||||||
|
"""
|
||||||
|
def _with_joins(sel, *, include_brand: bool = False):
|
||||||
|
# ترتیب joinها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
|
||||||
|
stmt = (
|
||||||
|
sel.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
|
)
|
||||||
|
if include_brand:
|
||||||
|
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
scope_conditions = []
|
||||||
|
if project_id is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||||
|
if warehouse_id is not None:
|
||||||
|
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||||
|
what_conditions = []
|
||||||
|
if material_id is not None:
|
||||||
|
what_conditions.append(InventoryItem.material_id == material_id)
|
||||||
|
if brand_id is not None:
|
||||||
|
what_conditions.append(InventoryItem.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
||||||
|
|
||||||
|
# «روز» بر مبنای تقویم تهران است؛ بدون این تبدیل، تراکنشهای بامداد
|
||||||
|
# (۰۰:۰۰ تا ۰۳:۳۰ تهران) به روز قبلِ UTC میافتادند.
|
||||||
|
day_expr = func.date_trunc(
|
||||||
|
"day", func.timezone("Asia/Tehran", InventoryTransaction.transaction_date)
|
||||||
|
).label("day")
|
||||||
|
# ریزتراکنشها بهازای (نوع، مشخصات، برند، انبار) گروه میشوند تا هم تجمیع
|
||||||
|
# روزانه و هم شکست چندبعدی نمودار از یک کوئری واحد بیایند (بدون تکرار رکورد).
|
||||||
|
detail_rows = (
|
||||||
|
await db.execute(
|
||||||
|
_with_joins(
|
||||||
|
select(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
func.sum(InventoryTransaction.quantity),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= date_from,
|
||||||
|
InventoryTransaction.transaction_date < date_to,
|
||||||
|
*scope_conditions,
|
||||||
|
*what_conditions,
|
||||||
|
)
|
||||||
|
.group_by(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
),
|
||||||
|
include_brand=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
opening = _qty(
|
||||||
|
await db.scalar(
|
||||||
|
_with_joins(
|
||||||
|
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
|
||||||
|
InventoryTransaction.transaction_date < date_from,
|
||||||
|
*scope_conditions,
|
||||||
|
*what_conditions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def category_of(tx_type: str) -> str:
|
||||||
|
if tx_type == "PURCHASE":
|
||||||
|
return "inflow"
|
||||||
|
if tx_type == "RETURN":
|
||||||
|
return "returns"
|
||||||
|
if tx_type == "WITHDRAWAL":
|
||||||
|
return "siteOut"
|
||||||
|
if tx_type in WASTE_TYPES:
|
||||||
|
return "waste"
|
||||||
|
# انتقالها/شمارش/یافتشده — خارج از چهار سبد نمایشی، علامتدار
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
||||||
|
breakdown_by_day: dict[date_cls, list[dict]] = {}
|
||||||
|
for day, tx_type, specs, brand_name, warehouse_name, signed in detail_rows:
|
||||||
|
day_key = day.date() if isinstance(day, datetime) else day
|
||||||
|
tx_type = str(tx_type)
|
||||||
|
qty = _qty(signed)
|
||||||
|
day_types = signed_by_day.setdefault(day_key, {})
|
||||||
|
day_types[tx_type] = round(day_types.get(tx_type, 0.0) + qty, 4)
|
||||||
|
breakdown_by_day.setdefault(day_key, []).append({
|
||||||
|
"category": category_of(tx_type),
|
||||||
|
"transaction_type": tx_type,
|
||||||
|
"specs": specs or None,
|
||||||
|
"brand": brand_name or None,
|
||||||
|
"warehouse": warehouse_name,
|
||||||
|
"quantity": qty,
|
||||||
|
})
|
||||||
|
|
||||||
|
unit = None
|
||||||
|
if material_id is not None:
|
||||||
|
unit_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Unit.code, Unit.name_fa)
|
||||||
|
.select_from(Material)
|
||||||
|
.join(Unit, Unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.id == material_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if unit_row is not None:
|
||||||
|
unit = {"code": unit_row[0], "name_fa": unit_row[1]}
|
||||||
|
|
||||||
|
days = []
|
||||||
|
running = opening
|
||||||
|
current = date_from.date()
|
||||||
|
while current < date_to.date():
|
||||||
|
types = signed_by_day.get(current, {})
|
||||||
|
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
|
||||||
|
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
|
||||||
|
running = round(running + sum(types.values()), 4)
|
||||||
|
days.append({
|
||||||
|
"date": current.isoformat(),
|
||||||
|
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
|
||||||
|
"outbound": outbound,
|
||||||
|
"waste": waste,
|
||||||
|
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
||||||
|
"balance": running,
|
||||||
|
"breakdown": breakdown_by_day.get(current, []),
|
||||||
|
})
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 4 — Daily control (today's site operations)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def daily_control(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
day: date_cls,
|
||||||
|
warehouse_id=None,
|
||||||
|
) -> dict:
|
||||||
|
"""One day (Tehran calendar): outbound / return / waste / purchase ticket
|
||||||
|
counts + quantities, the exact performer names, and the operation detail."""
|
||||||
|
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
scope_conditions = []
|
||||||
|
if project_id is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||||
|
if warehouse_id is not None:
|
||||||
|
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||||
|
|
||||||
|
count_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
func.count().label("tickets"),
|
||||||
|
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.group_by(InventoryTransaction.transaction_type)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
|
||||||
|
|
||||||
|
performers_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction.performed_by,
|
||||||
|
func.count().label("tickets"),
|
||||||
|
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.group_by(InventoryTransaction.performed_by)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
detail_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction,
|
||||||
|
Warehouse.name,
|
||||||
|
Material.name,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Unit.code,
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
InventoryTransaction.transaction_type.in_(
|
||||||
|
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
|
||||||
|
),
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.order_by(InventoryTransaction.transaction_date.desc())
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
zero = {"tickets": 0, "qty": 0.0}
|
||||||
|
items = []
|
||||||
|
for tx, wh_name, mat_name, specs, ucode in detail_rows:
|
||||||
|
items.append({
|
||||||
|
"id": str(tx.id),
|
||||||
|
"transaction_type": str(tx.transaction_type),
|
||||||
|
"quantity": _qty(tx.quantity),
|
||||||
|
"unit_code": ucode,
|
||||||
|
"performed_by": tx.performed_by,
|
||||||
|
"note": tx.note,
|
||||||
|
"warehouse": wh_name,
|
||||||
|
"material": mat_name,
|
||||||
|
"specs": specs or {},
|
||||||
|
"transaction_date": tx.transaction_date.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"date": day.isoformat(),
|
||||||
|
"outbound": by_type.get("WITHDRAWAL", zero),
|
||||||
|
"returns": by_type.get("RETURN", zero),
|
||||||
|
"waste": {
|
||||||
|
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
|
||||||
|
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
|
||||||
|
},
|
||||||
|
"purchases": by_type.get("PURCHASE", zero),
|
||||||
|
"performers": [
|
||||||
|
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
|
||||||
|
for name, tickets, qty in performers_rows
|
||||||
|
],
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 8 — Procurement workload per project (project density)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
|
||||||
|
"""Per project: offered quotes still open + chosen purchase lines (open
|
||||||
|
vs fully received). Chosen lines attribute to item_decisions.project_id
|
||||||
|
(the purchased destination); offered quotes to any linked project."""
|
||||||
|
received_sq = (
|
||||||
|
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
|
||||||
|
.group_by(ItemReceipt.item_decision_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
order_conditions = [ItemDecision.status == "chosen"]
|
||||||
|
if project_id is not None:
|
||||||
|
order_conditions.append(ItemDecision.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
order_conditions.append(ItemDecision.project_id.in_(project_ids))
|
||||||
|
|
||||||
|
order_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
Project.id,
|
||||||
|
Project.name,
|
||||||
|
func.count().label("chosen_items"),
|
||||||
|
func.sum(LineItem.quantity).label("ordered_qty"),
|
||||||
|
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("open_items"),
|
||||||
|
)
|
||||||
|
.select_from(ItemDecision)
|
||||||
|
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||||
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
|
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
|
||||||
|
.where(*order_conditions)
|
||||||
|
.group_by(Project.id, Project.name)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
offered_by_project: dict[uuid.UUID | None, set] = {}
|
||||||
|
offered_primary = (
|
||||||
|
await db.execute(
|
||||||
|
select(Quote.id, Quote.project_id).where(
|
||||||
|
Quote.status == "offered", Quote.project_id.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for quote_id, pid in offered_primary:
|
||||||
|
offered_by_project.setdefault(pid, set()).add(quote_id)
|
||||||
|
offered_junction = (
|
||||||
|
await db.execute(
|
||||||
|
select(QuoteProjects.quote_id, QuoteProjects.project_id)
|
||||||
|
.join(Quote, Quote.id == QuoteProjects.quote_id)
|
||||||
|
.where(Quote.status == "offered")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for quote_id, pid in offered_junction:
|
||||||
|
offered_by_project.setdefault(pid, set()).add(quote_id)
|
||||||
|
|
||||||
|
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
|
||||||
|
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
|
||||||
|
names: dict[uuid.UUID, str] = {}
|
||||||
|
if project_ids_needed:
|
||||||
|
names = {
|
||||||
|
p.id: p.name
|
||||||
|
for p in (
|
||||||
|
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
|
||||||
|
projects = []
|
||||||
|
for pid, _pname, chosen, ordered, received, open_items in order_rows:
|
||||||
|
if pid is None:
|
||||||
|
offered = 0
|
||||||
|
else:
|
||||||
|
offered = len(offered_by_project.pop(pid, set()))
|
||||||
|
if project_id is not None and pid != project_id:
|
||||||
|
offered = 0
|
||||||
|
elif project_ids is not None and pid not in project_ids:
|
||||||
|
offered = 0
|
||||||
|
projects.append({
|
||||||
|
"id": str(pid) if pid else None,
|
||||||
|
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
|
||||||
|
"offered_quotes": offered,
|
||||||
|
"chosen_items": chosen,
|
||||||
|
"open_orders": open_items,
|
||||||
|
"ordered_qty": _qty(ordered),
|
||||||
|
"received_qty": _qty(received),
|
||||||
|
})
|
||||||
|
# offered-only projects that have no chosen lines yet
|
||||||
|
for pid, quote_ids in offered_by_project.items():
|
||||||
|
if not quote_ids:
|
||||||
|
continue
|
||||||
|
if project_id is not None and pid != project_id:
|
||||||
|
continue
|
||||||
|
if project_ids is not None and (pid is None or pid not in project_ids):
|
||||||
|
continue
|
||||||
|
projects.append({
|
||||||
|
"id": str(pid) if pid else None,
|
||||||
|
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
|
||||||
|
"offered_quotes": len(quote_ids),
|
||||||
|
"chosen_items": 0,
|
||||||
|
"open_orders": 0,
|
||||||
|
"ordered_qty": 0.0,
|
||||||
|
"received_qty": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
|
||||||
|
return {"data": {"projects": projects}}
|
||||||
@@ -290,6 +290,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
@@ -319,6 +320,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=-quantity,
|
quantity=-quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.catalog import Brand, Material, Unit
|
||||||
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
|
from app.models.procurement import Project
|
||||||
|
from app.models.project_transfer import ProjectTransfer
|
||||||
|
from app.schemas.project_transfers import (
|
||||||
|
ProjectTransferCreate,
|
||||||
|
ProjectTransferReceive,
|
||||||
|
ProjectTransferUpdate,
|
||||||
|
)
|
||||||
|
from app.services.inventory_service import (
|
||||||
|
_find_inventory,
|
||||||
|
_get_or_create_inventory,
|
||||||
|
canonical_specs_hash,
|
||||||
|
)
|
||||||
|
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectTransfer)
|
||||||
|
.where(ProjectTransfer.id == transfer_id)
|
||||||
|
.options(
|
||||||
|
selectinload(ProjectTransfer.from_project),
|
||||||
|
selectinload(ProjectTransfer.to_project),
|
||||||
|
selectinload(ProjectTransfer.from_warehouse),
|
||||||
|
selectinload(ProjectTransfer.to_warehouse),
|
||||||
|
selectinload(ProjectTransfer.material),
|
||||||
|
selectinload(ProjectTransfer.brand),
|
||||||
|
selectinload(ProjectTransfer.unit),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transfer = await db.scalar(stmt)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
async def create_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
payload: ProjectTransferCreate,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
transferred_by: str | None = None,
|
||||||
|
) -> ProjectTransfer:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
# Validate source warehouse
|
||||||
|
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
|
||||||
|
if from_wh is None:
|
||||||
|
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
|
||||||
|
|
||||||
|
# RBAC: PM can only send from their own project
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if from_wh.project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
|
||||||
|
|
||||||
|
from_proj = await db.get(Project, from_wh.project_id)
|
||||||
|
to_proj = await db.get(Project, payload.to_project_id)
|
||||||
|
if to_proj is None:
|
||||||
|
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||||
|
|
||||||
|
if from_wh.project_id == payload.to_project_id:
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه مقصد نمیتواند همان پروژه مبدأ باشد (از انتقال بینانباری استفاده کنید)",
|
||||||
|
field="to_project_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate material, brand, unit
|
||||||
|
mat = await db.get(Material, payload.material_id)
|
||||||
|
if mat is None:
|
||||||
|
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
|
||||||
|
|
||||||
|
if payload.brand_id is not None:
|
||||||
|
brand = await db.get(Brand, payload.brand_id)
|
||||||
|
if brand is None:
|
||||||
|
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||||
|
|
||||||
|
unit = await db.get(Unit, payload.unit_id)
|
||||||
|
if unit is None:
|
||||||
|
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||||
|
|
||||||
|
# Requirement 2: Canonical specs hash from inventory_service
|
||||||
|
specs = payload.specs or {}
|
||||||
|
specs_hash = canonical_specs_hash(specs)
|
||||||
|
|
||||||
|
# Check stock in source warehouse with SELECT ... FOR UPDATE
|
||||||
|
source_item = await _find_inventory(
|
||||||
|
db,
|
||||||
|
payload.from_warehouse_id,
|
||||||
|
payload.material_id,
|
||||||
|
payload.brand_id,
|
||||||
|
specs_hash,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
|
||||||
|
if source_item is None:
|
||||||
|
candidates_res = await db.execute(
|
||||||
|
select(InventoryItem)
|
||||||
|
.where(
|
||||||
|
InventoryItem.warehouse_id == payload.from_warehouse_id,
|
||||||
|
InventoryItem.material_id == payload.material_id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
candidates = candidates_res.scalars().all()
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
source_item = candidates[0]
|
||||||
|
elif len(candidates) > 1:
|
||||||
|
best_cand = None
|
||||||
|
best_score = -1
|
||||||
|
p_keys = set(specs.keys())
|
||||||
|
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
|
||||||
|
|
||||||
|
for cand in candidates:
|
||||||
|
score = 0
|
||||||
|
if cand.brand_id == payload.brand_id:
|
||||||
|
score += 10
|
||||||
|
elif cand.brand_id is None and payload.brand_id is None:
|
||||||
|
score += 10
|
||||||
|
elif cand.brand_id is None:
|
||||||
|
score += 4
|
||||||
|
|
||||||
|
c_specs = cand.specs or {}
|
||||||
|
c_keys = set(c_specs.keys())
|
||||||
|
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
|
||||||
|
|
||||||
|
for pk in p_keys:
|
||||||
|
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
|
||||||
|
score += 5
|
||||||
|
for pv in p_vals:
|
||||||
|
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
if cand.quantity >= payload.quantity:
|
||||||
|
score += 2
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_cand = cand
|
||||||
|
|
||||||
|
source_item = best_cand
|
||||||
|
|
||||||
|
if source_item is None or source_item.quantity < payload.quantity:
|
||||||
|
avail = source_item.quantity if source_item else Decimal("0")
|
||||||
|
raise ValidationError(
|
||||||
|
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
|
||||||
|
field="quantity",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct from source warehouse immediately
|
||||||
|
source_item.quantity -= payload.quantity
|
||||||
|
|
||||||
|
transfer_pair_id = uuid.uuid4()
|
||||||
|
now_dt = payload.transfer_date or datetime.now(timezone.utc)
|
||||||
|
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
|
||||||
|
if payload.note:
|
||||||
|
note_text += f" — {payload.note}"
|
||||||
|
|
||||||
|
out_tx = InventoryTransaction(
|
||||||
|
inventory_id=source_item.id,
|
||||||
|
warehouse_id=from_wh.id,
|
||||||
|
transaction_type="TRANSFER_OUT",
|
||||||
|
quantity=-payload.quantity,
|
||||||
|
transfer_pair_id=transfer_pair_id,
|
||||||
|
performed_by=transferred_by,
|
||||||
|
transaction_date=now_dt,
|
||||||
|
note=note_text,
|
||||||
|
)
|
||||||
|
db.add(out_tx)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
actual_brand_id = source_item.brand_id
|
||||||
|
actual_specs = source_item.specs if source_item.specs is not None else specs
|
||||||
|
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
|
||||||
|
|
||||||
|
transfer = ProjectTransfer(
|
||||||
|
from_project_id=from_wh.project_id,
|
||||||
|
from_warehouse_id=from_wh.id,
|
||||||
|
to_project_id=payload.to_project_id,
|
||||||
|
material_id=payload.material_id,
|
||||||
|
brand_id=actual_brand_id,
|
||||||
|
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
specs=actual_specs,
|
||||||
|
specs_hash=actual_specs_hash,
|
||||||
|
note=payload.note,
|
||||||
|
status="in_transit",
|
||||||
|
out_transaction_id=out_tx.id,
|
||||||
|
transfer_date=now_dt,
|
||||||
|
transferred_by=transferred_by,
|
||||||
|
)
|
||||||
|
db.add(transfer)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await get_project_transfer_or_404(db, transfer.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferUpdate,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
) -> ProjectTransfer:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
transfer = await db.scalar(
|
||||||
|
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||||
|
)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||||
|
|
||||||
|
# RBAC (Requirement 4)
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if transfer.from_project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به ویرایش انتقالهای پروژه مبدأ خود هستید")
|
||||||
|
|
||||||
|
# Status Lock Guard (Requirement 4)
|
||||||
|
if transfer.status == "received":
|
||||||
|
raise ConflictError(
|
||||||
|
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
|
||||||
|
)
|
||||||
|
if transfer.status != "in_transit":
|
||||||
|
raise ConflictError("فقط انتقالهای در راه (in_transit) قابل ویرایش هستند")
|
||||||
|
|
||||||
|
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
|
||||||
|
new_to_proj = await db.get(Project, payload.to_project_id)
|
||||||
|
if new_to_proj is None:
|
||||||
|
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||||
|
if payload.to_project_id == transfer.from_project_id:
|
||||||
|
raise ValidationError("پروژه مقصد نمیتواند همان پروژه مبدأ باشد", field="to_project_id")
|
||||||
|
transfer.to_project_id = payload.to_project_id
|
||||||
|
|
||||||
|
# Quantity adjustment (reconciles source stock difference)
|
||||||
|
if payload.quantity is not None and payload.quantity != transfer.quantity:
|
||||||
|
delta = payload.quantity - transfer.quantity
|
||||||
|
source_item = await _find_inventory(
|
||||||
|
db,
|
||||||
|
transfer.from_warehouse_id,
|
||||||
|
transfer.material_id,
|
||||||
|
transfer.brand_id,
|
||||||
|
transfer.specs_hash,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if delta > 0:
|
||||||
|
if source_item is None or source_item.quantity < delta:
|
||||||
|
avail = source_item.quantity if source_item else Decimal("0")
|
||||||
|
raise ValidationError(
|
||||||
|
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
|
||||||
|
field="quantity",
|
||||||
|
)
|
||||||
|
source_item.quantity -= delta
|
||||||
|
else:
|
||||||
|
if source_item is not None:
|
||||||
|
source_item.quantity += abs(delta)
|
||||||
|
|
||||||
|
transfer.quantity = payload.quantity
|
||||||
|
|
||||||
|
# Update out_transaction
|
||||||
|
if transfer.out_transaction_id is not None:
|
||||||
|
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if out_tx is not None:
|
||||||
|
out_tx.quantity = -payload.quantity
|
||||||
|
|
||||||
|
if payload.note is not None:
|
||||||
|
transfer.note = payload.note
|
||||||
|
|
||||||
|
if payload.transfer_date is not None:
|
||||||
|
transfer.transfer_date = payload.transfer_date
|
||||||
|
if transfer.out_transaction_id is not None:
|
||||||
|
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if out_tx is not None:
|
||||||
|
out_tx.transaction_date = payload.transfer_date
|
||||||
|
|
||||||
|
return await get_project_transfer_or_404(db, transfer_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
) -> dict:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
transfer = await db.scalar(
|
||||||
|
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||||
|
)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||||
|
|
||||||
|
# RBAC (Requirement 4)
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if transfer.from_project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به لغو انتقالهای پروژه مبدأ خود هستید")
|
||||||
|
|
||||||
|
# Status Lock Guard (Requirement 4)
|
||||||
|
if transfer.status == "received":
|
||||||
|
raise ConflictError(
|
||||||
|
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reversibility (Requirement 4): Restore stock to source warehouse
|
||||||
|
source_item = await _find_inventory(
|
||||||
|
db,
|
||||||
|
transfer.from_warehouse_id,
|
||||||
|
transfer.material_id,
|
||||||
|
transfer.brand_id,
|
||||||
|
transfer.specs_hash,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if source_item is not None:
|
||||||
|
source_item.quantity += transfer.quantity
|
||||||
|
|
||||||
|
# Delete out_transaction
|
||||||
|
if transfer.out_transaction_id is not None:
|
||||||
|
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if out_tx is not None:
|
||||||
|
await db.delete(out_tx)
|
||||||
|
|
||||||
|
await db.delete(transfer)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferReceive,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
received_by: str | None = None,
|
||||||
|
) -> ProjectTransfer:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
transfer = await db.scalar(
|
||||||
|
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||||
|
)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||||
|
|
||||||
|
# RBAC (Requirement 4)
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if transfer.to_project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
|
||||||
|
|
||||||
|
# Lock Guard
|
||||||
|
if transfer.status == "received":
|
||||||
|
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
|
||||||
|
if transfer.status != "in_transit":
|
||||||
|
raise ConflictError("این انتقال در وضعیت در راه نیست")
|
||||||
|
|
||||||
|
# Requirement 1: 100% full delivery logic (no partial receipt)
|
||||||
|
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
|
||||||
|
if alloc_sum != transfer.quantity:
|
||||||
|
raise ValidationError(
|
||||||
|
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
|
||||||
|
field="allocations",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate destination warehouses belong to destination project
|
||||||
|
wh_ids = [a.warehouse_id for a in payload.allocations]
|
||||||
|
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
|
||||||
|
wh_map = {w.id: w for w in wh_rows}
|
||||||
|
|
||||||
|
for a in payload.allocations:
|
||||||
|
wh = wh_map.get(a.warehouse_id)
|
||||||
|
if wh is None or wh.project_id != transfer.to_project_id:
|
||||||
|
raise ValidationError(
|
||||||
|
"انبار انتخابشده معتبر نیست یا متعلق به پروژه مقصد نمیباشد",
|
||||||
|
field="allocations.warehouse_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
from_proj = await db.get(Project, transfer.from_project_id)
|
||||||
|
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
|
||||||
|
|
||||||
|
out_tx = (
|
||||||
|
await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if transfer.out_transaction_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
|
||||||
|
now_dt = payload.received_at or datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Add stock to destination warehouse(s) via TRANSFER_IN
|
||||||
|
for a in payload.allocations:
|
||||||
|
dest_item = await _get_or_create_inventory(
|
||||||
|
db,
|
||||||
|
a.warehouse_id,
|
||||||
|
transfer.material_id,
|
||||||
|
transfer.brand_id,
|
||||||
|
transfer.unit_id,
|
||||||
|
transfer.specs,
|
||||||
|
transfer.specs_hash,
|
||||||
|
)
|
||||||
|
dest_item.quantity += a.quantity
|
||||||
|
|
||||||
|
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
|
||||||
|
if payload.note:
|
||||||
|
note_text += f" — {payload.note}"
|
||||||
|
|
||||||
|
in_tx = InventoryTransaction(
|
||||||
|
inventory_id=dest_item.id,
|
||||||
|
warehouse_id=a.warehouse_id,
|
||||||
|
transaction_type="TRANSFER_IN",
|
||||||
|
quantity=a.quantity,
|
||||||
|
transfer_pair_id=pair_id,
|
||||||
|
performed_by=received_by,
|
||||||
|
transaction_date=now_dt,
|
||||||
|
note=note_text,
|
||||||
|
)
|
||||||
|
db.add(in_tx)
|
||||||
|
|
||||||
|
transfer.status = "received"
|
||||||
|
transfer.received_at = now_dt
|
||||||
|
transfer.received_by = received_by
|
||||||
|
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
|
||||||
|
transfer.receipt_allocations = [
|
||||||
|
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
|
||||||
|
for a in payload.allocations
|
||||||
|
]
|
||||||
|
|
||||||
|
return await get_project_transfer_or_404(db, transfer_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_project_transfers(
|
||||||
|
db: AsyncSession,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
|
||||||
|
status: str | None = None,
|
||||||
|
) -> list[ProjectTransfer]:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectTransfer)
|
||||||
|
.options(
|
||||||
|
selectinload(ProjectTransfer.from_project),
|
||||||
|
selectinload(ProjectTransfer.to_project),
|
||||||
|
selectinload(ProjectTransfer.from_warehouse),
|
||||||
|
selectinload(ProjectTransfer.to_warehouse),
|
||||||
|
selectinload(ProjectTransfer.material),
|
||||||
|
selectinload(ProjectTransfer.brand),
|
||||||
|
selectinload(ProjectTransfer.unit),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if project_id is not None:
|
||||||
|
if direction == "outgoing":
|
||||||
|
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
|
||||||
|
elif direction == "incoming":
|
||||||
|
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(
|
||||||
|
(ProjectTransfer.from_project_id == project_id)
|
||||||
|
| (ProjectTransfer.to_project_id == project_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(ProjectTransfer.status == status)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
return list(res.scalars().all())
|
||||||
@@ -7,6 +7,7 @@ same transaction.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
@@ -18,6 +19,7 @@ from app.models.procurement import (
|
|||||||
ItemTermsheet,
|
ItemTermsheet,
|
||||||
LineItem,
|
LineItem,
|
||||||
LineItemProjects,
|
LineItemProjects,
|
||||||
|
LineItemSpec,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
QuoteEvent,
|
QuoteEvent,
|
||||||
@@ -418,6 +420,78 @@ async def replace_quote_children(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
|
||||||
|
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
|
||||||
|
exists = await db.scalar(
|
||||||
|
select(QuoteProjects).where(
|
||||||
|
QuoteProjects.quote_id == quote_id,
|
||||||
|
QuoteProjects.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
|
||||||
|
"""Consolidate split line items when reverting a quote back to offered.
|
||||||
|
|
||||||
|
Line items sharing the exact same material, brand_material, unit, notes,
|
||||||
|
and identical specs are merged into a single consolidated row whose quantity
|
||||||
|
is the sum of all split rows. Secondary split rows are safely deleted.
|
||||||
|
"""
|
||||||
|
line_items = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if len(line_items) <= 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load all specs for these line items to compare canonical signatures
|
||||||
|
specs_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
specs_by_li: dict[UUID, list[tuple]] = {}
|
||||||
|
for sp in specs_rows:
|
||||||
|
sig = (
|
||||||
|
str(sp.attribute_definition_id),
|
||||||
|
str(sp.value_number) if sp.value_number is not None else "",
|
||||||
|
(sp.value_string or "").strip(),
|
||||||
|
str(sp.value_boolean) if sp.value_boolean is not None else "",
|
||||||
|
str(sp.value_date) if sp.value_date is not None else "",
|
||||||
|
)
|
||||||
|
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
|
||||||
|
|
||||||
|
# Group line items by identity
|
||||||
|
groups: dict[tuple, list[LineItem]] = {}
|
||||||
|
for li in line_items:
|
||||||
|
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
|
||||||
|
key = (
|
||||||
|
li.material_id,
|
||||||
|
li.brand_material_id,
|
||||||
|
li.unit_id,
|
||||||
|
(li.notes or "").strip(),
|
||||||
|
specs_sig,
|
||||||
|
)
|
||||||
|
groups.setdefault(key, []).append(li)
|
||||||
|
|
||||||
|
for key, items in groups.items():
|
||||||
|
if len(items) > 1:
|
||||||
|
primary = items[0]
|
||||||
|
total_qty = sum(Decimal(str(it.quantity)) for it in items)
|
||||||
|
primary.quantity = total_qty
|
||||||
|
|
||||||
|
secondary_ids = [it.id for it in items[1:]]
|
||||||
|
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||||
current = quote.status
|
current = quote.status
|
||||||
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
)
|
)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
|
# Requirement 2: Merge split line items back into single rows
|
||||||
|
await _merge_split_line_items_on_reopen(db, quote.id)
|
||||||
# LineItemProjects (candidate projects) are preserved untouched!
|
# LineItemProjects (candidate projects) are preserved untouched!
|
||||||
elif new_status == "accepted": # arrived -> accepted
|
elif new_status == "accepted": # arrived -> accepted
|
||||||
quote.arrived_date = None
|
quote.arrived_date = None
|
||||||
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
||||||
)
|
)
|
||||||
termsheet_id = ts_ids_by_index[index]
|
termsheet_id = ts_ids_by_index[index]
|
||||||
|
|
||||||
|
li_row = await db.scalar(
|
||||||
|
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
|
||||||
|
)
|
||||||
|
if li_row is None:
|
||||||
|
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
|
||||||
|
|
||||||
|
allocations = decision_in.allocations or []
|
||||||
|
if allocations:
|
||||||
|
# Requirement 3: Validate no duplicate projects within allocations of this item
|
||||||
|
alloc_pids = [a.project_id for a in allocations]
|
||||||
|
if len(alloc_pids) != len(set(alloc_pids)):
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه تکراری برای یک قلم کالا در ردیفهای تخصیص انتخاب شده است",
|
||||||
|
field="item_decisions.allocations",
|
||||||
|
)
|
||||||
|
# Requirement 5: Decimal precision check for sum of allocation quantities
|
||||||
|
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
|
||||||
|
target_qty = Decimal(str(li_row.quantity))
|
||||||
|
if alloc_sum != target_qty:
|
||||||
|
raise ValidationError(
|
||||||
|
f"مجموع مقادیر تخصیصیافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
|
||||||
|
field="item_decisions.allocations",
|
||||||
|
)
|
||||||
|
# Validate all projects exist
|
||||||
|
for a in allocations:
|
||||||
|
if await db.get(Project, a.project_id) is None:
|
||||||
|
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
|
||||||
|
|
||||||
|
if len(allocations) >= 2:
|
||||||
|
# 1. Update the original line item to the first allocation quantity
|
||||||
|
first_alloc = allocations[0]
|
||||||
|
li_row.quantity = first_alloc.quantity
|
||||||
|
decision_in.project_id = first_alloc.project_id
|
||||||
|
|
||||||
|
# Query all existing item_termsheets and line_item_specs for copying
|
||||||
|
orig_termsheets = (
|
||||||
|
await db.execute(
|
||||||
|
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
orig_specs = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
|
||||||
|
for split_alloc in allocations[1:]:
|
||||||
|
new_li = LineItem(
|
||||||
|
quote_id=quote.id,
|
||||||
|
material_id=li_row.material_id,
|
||||||
|
brand_material_id=li_row.brand_material_id,
|
||||||
|
unit_id=li_row.unit_id,
|
||||||
|
quantity=split_alloc.quantity,
|
||||||
|
notes=li_row.notes,
|
||||||
|
)
|
||||||
|
db.add(new_li)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Copy LineItemSpec
|
||||||
|
for sp in orig_specs:
|
||||||
|
db.add(
|
||||||
|
LineItemSpec(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
attribute_definition_id=sp.attribute_definition_id,
|
||||||
|
value_number=sp.value_number,
|
||||||
|
value_string=sp.value_string,
|
||||||
|
value_boolean=sp.value_boolean,
|
||||||
|
value_date=sp.value_date,
|
||||||
|
unit_id_at_time=sp.unit_id_at_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Requirement 1: Copy ALL payment options (ItemTermsheet)
|
||||||
|
chosen_new_it: ItemTermsheet | None = None
|
||||||
|
for it_orig in orig_termsheets:
|
||||||
|
price = (
|
||||||
|
decision_in.adjusted_unit_price
|
||||||
|
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
|
||||||
|
else it_orig.unit_price
|
||||||
|
)
|
||||||
|
copied_it = ItemTermsheet(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
termsheet_id=it_orig.termsheet_id,
|
||||||
|
unit_price=price,
|
||||||
|
min_order_qty=it_orig.min_order_qty,
|
||||||
|
)
|
||||||
|
db.add(copied_it)
|
||||||
|
if it_orig.termsheet_id == termsheet_id:
|
||||||
|
chosen_new_it = copied_it
|
||||||
|
|
||||||
|
if chosen_new_it is None:
|
||||||
|
chosen_new_it = ItemTermsheet(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
termsheet_id=termsheet_id,
|
||||||
|
unit_price=decision_in.adjusted_unit_price,
|
||||||
|
)
|
||||||
|
db.add(chosen_new_it)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create ItemDecision for the split line item
|
||||||
|
new_decision = ItemDecision(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
status="chosen",
|
||||||
|
chosen_item_termsheet_id=chosen_new_it.id,
|
||||||
|
adjusted_unit_price=decision_in.adjusted_unit_price,
|
||||||
|
adjusted_notes=decision_in.adjusted_notes,
|
||||||
|
project_id=split_alloc.project_id,
|
||||||
|
decided_at=utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_decision)
|
||||||
|
|
||||||
|
# Requirement 4: sync QuoteProjects & LineItemProjects
|
||||||
|
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
|
||||||
|
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
|
||||||
|
elif len(allocations) == 1:
|
||||||
|
decision_in.project_id = allocations[0].project_id
|
||||||
|
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(
|
select(ItemTermsheet).where(
|
||||||
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
||||||
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
if decision.project_id is not None:
|
if decision.project_id is not None:
|
||||||
if await db.get(Project, decision.project_id) is None:
|
if await db.get(Project, decision.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
||||||
|
# Requirement 4: sync QuoteProjects
|
||||||
|
await _ensure_quote_project(db, quote.id, decision.project_id)
|
||||||
chosen_ts_ids.add(termsheet_id)
|
chosen_ts_ids.add(termsheet_id)
|
||||||
else: # rejected
|
else: # rejected
|
||||||
if decision is None:
|
if decision is None:
|
||||||
@@ -903,6 +1100,17 @@ async def quote_stats(
|
|||||||
*conditions,
|
*conditions,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Procurement funnel (cumulative, not the current-state counts above):
|
||||||
|
# a quote counts as accepted/arrived if it EVER reached that stage.
|
||||||
|
funnel_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("offered_total"),
|
||||||
|
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
|
||||||
|
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
|
||||||
|
).where(*conditions)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
return {
|
return {
|
||||||
"total": sum(counts.values()),
|
"total": sum(counts.values()),
|
||||||
"offered": counts["offered"],
|
"offered": counts["offered"],
|
||||||
@@ -911,6 +1119,11 @@ async def quote_stats(
|
|||||||
"rejected": counts["rejected"],
|
"rejected": counts["rejected"],
|
||||||
"expired": counts["expired"],
|
"expired": counts["expired"],
|
||||||
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
|
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
|
||||||
|
"funnel": {
|
||||||
|
"offered_total": funnel_row.offered_total,
|
||||||
|
"accepted_total": funnel_row.accepted_total,
|
||||||
|
"arrived_total": funnel_row.arrived_total,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
|
|||||||
|
|
||||||
USER_PLAN = [
|
USER_PLAN = [
|
||||||
# (casdoor username, first, last, role)
|
# (casdoor username, first, last, role)
|
||||||
|
("ali.mohaghegh", "علی", "محقق", "admin"),
|
||||||
("razaei", "علی", "رضایی", "admin"),
|
("razaei", "علی", "رضایی", "admin"),
|
||||||
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
||||||
("mohaghegh", "محمد", "محقق", "pm"),
|
("mohaghegh", "محمد", "محقق", "pm"),
|
||||||
@@ -261,8 +262,11 @@ async def run() -> None:
|
|||||||
material_by_name: dict[str, Material] = {}
|
material_by_name: dict[str, Material] = {}
|
||||||
brand_material_by_material: dict[str, object] = {}
|
brand_material_by_material: dict[str, object] = {}
|
||||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||||
|
cat = await get_category(db, cat_slug)
|
||||||
material = Material(
|
material = Material(
|
||||||
name=name,
|
name=name,
|
||||||
|
topic=cat.name,
|
||||||
|
category_id=cat.id, # v2.8: dashboard Category→Material cascade
|
||||||
canonical_unit_id=unit_ids[unit_code],
|
canonical_unit_id=unit_ids[unit_code],
|
||||||
status="active",
|
status="active",
|
||||||
)
|
)
|
||||||
@@ -440,6 +444,86 @@ async def run() -> None:
|
|||||||
))
|
))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
# ---- MVP dashboard demo ledger -----------------------------------
|
||||||
|
# Multi-day history so every widget has real data to show:
|
||||||
|
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
|
||||||
|
# - withdrawals/returns/waste over the last ~12 days → flow series
|
||||||
|
# - tickets dated TODAY (performed_by names) → daily control
|
||||||
|
# Quantities are hand-checked to keep every row >= 0.
|
||||||
|
|
||||||
|
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
|
||||||
|
mat = material_by_name[MATERIALS[mat_idx][0]]
|
||||||
|
wh = warehouse_rows[project_idx][wh_idx]
|
||||||
|
inv = await db.scalar(select(InventoryItem).where(
|
||||||
|
InventoryItem.warehouse_id == wh.id,
|
||||||
|
InventoryItem.material_id == mat.id,
|
||||||
|
InventoryItem.specs_hash == "default",
|
||||||
|
))
|
||||||
|
if inv is None:
|
||||||
|
inv = InventoryItem(
|
||||||
|
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
|
||||||
|
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
|
||||||
|
specs={}, specs_hash="default",
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
await db.flush()
|
||||||
|
return inv
|
||||||
|
|
||||||
|
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
|
||||||
|
days_ago: int, performed_by: str, note: str | None = None) -> None:
|
||||||
|
inv.quantity += Decimal(str(qty))
|
||||||
|
db.add(InventoryTransaction(
|
||||||
|
inventory_id=inv.id,
|
||||||
|
warehouse_id=wh.id,
|
||||||
|
transaction_type=tx_type,
|
||||||
|
quantity=Decimal(str(qty)),
|
||||||
|
performed_by=performed_by,
|
||||||
|
note=note or "تاریخچه دمو داشبورد",
|
||||||
|
transaction_date=d(days_ago),
|
||||||
|
))
|
||||||
|
|
||||||
|
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
|
||||||
|
|
||||||
|
# dead stock: cable + switches bought long ago, untouched since
|
||||||
|
cable = await ensure_inv(0, 0, 0)
|
||||||
|
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
|
||||||
|
keys = await ensure_inv(1, 0, 0)
|
||||||
|
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
|
||||||
|
|
||||||
|
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
|
||||||
|
cement = await ensure_inv(4, 2, 0)
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
|
||||||
|
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
|
||||||
|
|
||||||
|
blocks = await ensure_inv(5, 2, 1)
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
|
||||||
|
|
||||||
|
pe16_a = await ensure_inv(6, 1, 0)
|
||||||
|
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
|
||||||
|
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
|
||||||
|
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
|
||||||
|
|
||||||
|
pe16_b = await ensure_inv(6, 1, 1)
|
||||||
|
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
|
||||||
|
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
|
||||||
|
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
|
||||||
|
|
||||||
|
pe25 = await ensure_inv(7, 1, 0)
|
||||||
|
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
|
||||||
|
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
|
||||||
|
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# summary
|
# summary
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/srv/backend/app
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- traefik_network
|
- traefik_network
|
||||||
- default
|
- default
|
||||||
@@ -347,6 +350,78 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
|
||||||
|
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
|
||||||
|
# created additively at startup) and reads ERP users/user_panels for SSO.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pm-api:
|
||||||
|
image: v15-pm-api:latest
|
||||||
|
build:
|
||||||
|
context: ./pm/backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pm-api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- pm_files:/data/files
|
||||||
|
- ./pm/backend/app:/srv/pm/app
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Same database as the ERP — PM tables are additive, created at startup.
|
||||||
|
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
|
||||||
|
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
|
||||||
|
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
|
||||||
|
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
||||||
|
PM_FILES_DIR: /data/files
|
||||||
|
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
||||||
|
# ERP integration — in-network service URLs.
|
||||||
|
PM_ERP_BASE_URL: http://api:8000
|
||||||
|
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||||
|
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
|
||||||
|
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
||||||
|
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
||||||
|
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
||||||
|
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
|
pm-frontend:
|
||||||
|
image: v15-pm-frontend:latest
|
||||||
|
build:
|
||||||
|
context: ./pm/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pm-frontend
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- pm-api
|
||||||
|
|
||||||
|
# PM platform under /pm on the same domain and the same Traefik router.
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
|
||||||
|
- traefik.http.routers.pm.entrypoints=web
|
||||||
|
# Higher priority than the ERP frontend Host router — otherwise /pm/api
|
||||||
|
# would reach the ERP SPA (POST → 405).
|
||||||
|
- traefik.http.routers.pm.priority=100
|
||||||
|
- traefik.http.services.pm.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- traefik_network
|
||||||
|
- default
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 128m
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||||
@@ -363,3 +438,4 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
traefik_certs:
|
traefik_certs:
|
||||||
|
pm_files:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||||
|
|
||||||
|
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||||
|
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||||
|
|
||||||
|
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||||
|
|
||||||
|
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||||
|
(`backend/app/routers/inventory.py` — list_transactions)
|
||||||
|
|
||||||
|
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||||
|
|
||||||
|
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||||
|
|
||||||
|
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||||
|
|
||||||
|
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||||
|
|
||||||
|
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||||
|
|
||||||
|
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||||
|
|
||||||
|
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||||
|
|
||||||
|
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||||
|
|
||||||
|
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||||
|
|
||||||
|
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||||
|
|
||||||
|
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||||
|
|
||||||
|
## ۵. 🟡 Audit Trail ناقص است
|
||||||
|
|
||||||
|
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||||
|
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||||
|
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||||
|
- CRUD انبارها و تغییرات کاربران
|
||||||
|
|
||||||
|
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||||
|
|
||||||
|
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||||
|
|
||||||
|
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||||
|
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||||
|
|
||||||
|
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||||
|
|
||||||
|
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||||
|
|
||||||
|
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||||
|
|
||||||
|
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||||
|
|
||||||
|
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||||
|
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||||
|
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||||
|
|
||||||
|
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||||
|
|
||||||
|
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||||
|
|
||||||
|
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||||
|
|
||||||
|
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||||
|
|
||||||
|
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||||
|
|
||||||
|
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||||
|
|
||||||
|
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||||
|
|
||||||
|
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||||
|
|
||||||
|
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چیزهایی که بررسی شد و درست است ✅
|
||||||
|
|
||||||
|
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||||
|
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||||
|
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||||
|
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||||
|
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||||
|
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+390
@@ -28,6 +28,7 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.85.0",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
@@ -1366,6 +1367,32 @@
|
|||||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
|
||||||
|
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
|
||||||
@@ -1714,6 +1741,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
@@ -2003,6 +2042,69 @@
|
|||||||
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
@@ -2033,6 +2135,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||||
@@ -2127,6 +2235,133 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2155,6 +2390,24 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2201,6 +2454,25 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
|
||||||
|
"integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2645,6 +2917,36 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -2752,6 +3054,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
|
||||||
@@ -2884,6 +3237,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -2970,6 +3329,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.85.0",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
|
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
|
||||||
"short_name": "دفترچه تأمین",
|
"short_name": "مدیریت پروژه و تأمین",
|
||||||
"description": "سامانه تدارکات و مدیریت انبار پروژههای ساختمانی",
|
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
|
||||||
"dir": "rtl",
|
"dir": "rtl",
|
||||||
"lang": "fa",
|
"lang": "fa",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
|||||||
+294
-9
@@ -38,11 +38,18 @@ const WarehousesPage = lazy(() =>
|
|||||||
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
||||||
);
|
);
|
||||||
const OrganizationPage = lazy(() =>
|
const OrganizationPage = lazy(() =>
|
||||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||||
);
|
);
|
||||||
const AdminPanelPage = lazy(() =>
|
const AdminPanelPage = lazy(() =>
|
||||||
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
||||||
);
|
);
|
||||||
|
const RequesterHome = lazy(() =>
|
||||||
|
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
|
||||||
|
);
|
||||||
|
// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجتهای Power-BI-style
|
||||||
|
const MvpDashboardPage = lazy(() =>
|
||||||
|
import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })),
|
||||||
|
);
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@@ -56,6 +63,7 @@ function Loading() {
|
|||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
|
|
||||||
// در صورت داشتن شناسه پیشنویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
|
// در صورت داشتن شناسه پیشنویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
@@ -68,14 +76,249 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
||||||
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
||||||
}
|
}
|
||||||
|
if (user.role === "supervisor") {
|
||||||
|
// اگر سرپرست پروژهای نداشته باشد، دسترسی مسدود میشود
|
||||||
|
const hasProjects =
|
||||||
|
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||||
|
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||||
|
if (!hasProjects) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
||||||
|
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
|
||||||
|
<p className="mt-2 text-[12.5px] leading-6 text-muted">
|
||||||
|
شما به عنوان سرپرست پروژه در سیستم ثبت شدهاید اما هنوز هیچ پروژهای به شما تخصیص داده نشده است.
|
||||||
|
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}}
|
||||||
|
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
|
||||||
|
>
|
||||||
|
خروج از حساب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنلهای تخصیصی:
|
||||||
|
const panels = user.panels ?? {};
|
||||||
|
const isAdmin = user.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
|
||||||
|
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user.role === "procurement_manager");
|
||||||
|
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user.role === "pm" || user.role === "supervisor");
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user.role === "pm" || user.role === "supervisor");
|
||||||
|
|
||||||
|
const isProcurementRoute =
|
||||||
|
pathname === "/" ||
|
||||||
|
pathname.startsWith("/wizard") ||
|
||||||
|
pathname.startsWith("/suppliers") ||
|
||||||
|
pathname.startsWith("/projects") ||
|
||||||
|
pathname.startsWith("/units") ||
|
||||||
|
pathname.startsWith("/requester");
|
||||||
|
|
||||||
|
if (isProcurementRoute && !hasProcurement) {
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/inventory") && !hasInventory) {
|
||||||
|
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// صفحهی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
|
// صفحهی اصلی بر اساس لایه پنلها (جایگزین نقشها):
|
||||||
function HomeRoute() {
|
function HomeRoute() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
const panels = user?.panels ?? {};
|
||||||
return <DashboardPage />;
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager");
|
||||||
|
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
// ۱. اگر پنل تدارکات دارد:
|
||||||
|
if (hasProcurement) {
|
||||||
|
if (panels.procurement === "limited" && !isAdmin) {
|
||||||
|
return <Navigate to="/wizard" replace />;
|
||||||
|
}
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. اگر پنل انبار دارد:
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
|
||||||
|
// ۳. اگر پنل PM دارد:
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
|
||||||
|
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
|
||||||
|
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنلهای سامانه</h3>
|
||||||
|
<p className="mt-2 text-xs text-muted leading-6">
|
||||||
|
حساب کاربری شما فعال است اما در حال حاضر به هیچیک از پنلهای سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای تدارکات و پنلهای تدارکات (full یا limited)
|
||||||
|
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const panels = user?.panels ?? {};
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager" || user?.role === "pm");
|
||||||
|
|
||||||
|
if (hasProcurement) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
||||||
|
function PmPanelPage() {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src="/pm/"
|
||||||
|
title="مدیریت پروژه (PM)"
|
||||||
|
className="h-dvh w-full border-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای pm/supervisor
|
||||||
|
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const panels = user?.panels ?? {};
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
if (!hasPm) {
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager");
|
||||||
|
if (hasProcurement) {
|
||||||
|
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
}
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
||||||
|
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
|
if (!allowed) {
|
||||||
|
const panels = user?.panels ?? {};
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
|
||||||
|
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
return <Navigate to="/panel/pm" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
|
||||||
|
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
|
||||||
|
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const allowed =
|
||||||
|
user?.role === "admin" ||
|
||||||
|
user?.role === "supervisor" ||
|
||||||
|
user?.panels?.inventory === "limited";
|
||||||
|
if (!allowed) {
|
||||||
|
return <Navigate to="/inventory" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را میبیند)
|
||||||
|
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
|
||||||
|
if (!allowed) {
|
||||||
|
return <Navigate to="/inventory" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -97,10 +340,13 @@ export default function App() {
|
|||||||
path="/wizard"
|
path="/wizard"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<WizardPage />
|
<RequireProcurementWrite>
|
||||||
|
<WizardPage />
|
||||||
|
</RequireProcurementWrite>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/suppliers"
|
path="/suppliers"
|
||||||
element={
|
element={
|
||||||
@@ -144,6 +390,17 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequirePmProjects>
|
||||||
|
<MvpDashboardPage />
|
||||||
|
</RequirePmProjects>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/units"
|
path="/units"
|
||||||
element={
|
element={
|
||||||
@@ -177,7 +434,9 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequirePmProjects>
|
||||||
<ChangeInventoryPage />
|
<RequireInventoryChange>
|
||||||
|
<ChangeInventoryPage />
|
||||||
|
</RequireInventoryChange>
|
||||||
</RequirePmProjects>
|
</RequirePmProjects>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
@@ -197,18 +456,24 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequirePmProjects>
|
||||||
<WarehousesPage />
|
<RequireWarehouseManage>
|
||||||
|
<WarehousesPage />
|
||||||
|
</RequireWarehouseManage>
|
||||||
</RequirePmProjects>
|
</RequirePmProjects>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/inventory/org"
|
path="/inventory/org"
|
||||||
|
element={<Navigate to="/organization" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/organization"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequireOrganization>
|
||||||
<OrganizationPage />
|
<OrganizationPage />
|
||||||
</RequirePmProjects>
|
</RequireOrganization>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -220,6 +485,26 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
|
||||||
|
<Route
|
||||||
|
path="/requester"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequesterHome />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
|
||||||
|
<Route
|
||||||
|
path="/panel/pm"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequirePmPanel>
|
||||||
|
<PmPanelPage />
|
||||||
|
</RequirePmPanel>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,48 +1,84 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
||||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
|
||||||
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
|
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
|
||||||
// دقیقاً مثل منوی دفتر: عنوان «پنلها»، ماژول فعلی با استایل فعال (پر)
|
// ۱. مدیریت انبار (/inventory)
|
||||||
// + «✓ فعلی»، ماژول دیگر بهعنوان سوییچر، و در پایان جداکننده + خروج.
|
// ۲. مدیریت تدارکات (/)
|
||||||
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز میشود که همهی
|
// ۳. مدیریت پروژه (/panel/pm)
|
||||||
// پروژههای تخصیصیافته را نشان میدهد؛ پروژهی فعال با «✓ فعلی» مشخص است و
|
// ۴. سازماندهی (/organization)
|
||||||
// انتخاب هر پروژه فقط «پروژهی فعال» را عوض میکند (نشست/توکن دستنخورده).
|
|
||||||
export function AppSwitcherMenu({
|
export function AppSwitcherMenu({
|
||||||
activeModule,
|
activeModule,
|
||||||
canSwitchToProcurement = true,
|
canSwitchToProcurement = true,
|
||||||
}: {
|
}: {
|
||||||
activeModule: "warehouse" | "procurement";
|
activeModule: "warehouse" | "procurement" | "organization";
|
||||||
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
|
canSwitchToProcurement?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const { pathname } = useLocation();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setActiveProject = useAuthStore((s) => s.setActiveProject);
|
const panels = user?.panels ?? {};
|
||||||
|
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager");
|
||||||
|
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
// فقط نقشهای ادمین، مدیر پروژه و مدیر تدارکات به پنل سازماندهی دسترسی دارند
|
||||||
|
const canManageOrg =
|
||||||
|
isAdmin ||
|
||||||
|
user?.role === "procurement_manager" ||
|
||||||
|
user?.role === "pm";
|
||||||
|
|
||||||
const items: MenuItem[] = [];
|
const items: MenuItem[] = [];
|
||||||
|
|
||||||
const isPm = user?.role === "pm";
|
if (hasInventory) {
|
||||||
const pmProjects = isPm ? user?.projects ?? [] : [];
|
items.push({
|
||||||
const projectChildren: MenuItem[] | undefined =
|
label: "انبار",
|
||||||
activeModule === "warehouse" && isPm
|
to: "/inventory",
|
||||||
? pmProjects.map((p) => ({
|
active: activeModule === "warehouse",
|
||||||
label: p.name,
|
});
|
||||||
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض میشود
|
|
||||||
active: p.id === user?.project_id,
|
|
||||||
onClick: () => setActiveProject(p.id),
|
|
||||||
}))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (activeModule === "procurement") {
|
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
|
||||||
} else {
|
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
|
|
||||||
if (canSwitchToProcurement) {
|
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasProcurement && canSwitchToProcurement) {
|
||||||
|
items.push({
|
||||||
|
label: "تدارکات",
|
||||||
|
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
|
||||||
|
active: activeModule === "procurement",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPm) {
|
||||||
|
items.push({
|
||||||
|
label: "مدیریت پروژه",
|
||||||
|
to: "/panel/pm",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canManageOrg) {
|
||||||
|
items.push({
|
||||||
|
label: "سازماندهی",
|
||||||
|
to: "/organization",
|
||||||
|
active: activeModule === "organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
items.push({ label: "خروج از حساب", onClick: logout });
|
items.push({ label: "خروج از حساب", onClick: logout });
|
||||||
return <PanelMenu title="پنلها" items={items} />;
|
return <PanelMenu title="پنلها" items={items} />;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { normalize } from "../lib/normalize";
|
import { normalize } from "../lib/normalize";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
@@ -16,7 +17,11 @@ export interface ComboboxProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
|
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||||
|
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||||
|
pinnedIds?: string[];
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,6 +30,11 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// با ارائهی این تابع، دکمهی «×» داخل فیلد ظاهر میشود تا انتخاب فعلی
|
||||||
|
// (و فیلتر وابسته به آن) بهتنهایی پاک شود.
|
||||||
|
onClear?: () => void;
|
||||||
|
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
openOnFocus?: boolean;
|
openOnFocus?: boolean;
|
||||||
@@ -43,7 +53,9 @@ export function Combobox({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
|
pinnedIds,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
@@ -52,11 +64,17 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
onClear,
|
||||||
|
inputRef,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [highlight, setHighlight] = useState(-1);
|
const [highlight, setHighlight] = useState(-1);
|
||||||
|
// کلیکِ روی اینپوتِ در حالت فوکوسنشده، اول onFocus را صدا میزند و منو باز
|
||||||
|
// میشود؛ این نشانگر میگوید «همین لحظه با فوکوس باز شد» تا همان کلیک
|
||||||
|
// دوباره منو را نبندد. کلیکهای بعدی = تاگل باز/بسته.
|
||||||
|
const justOpenedByFocus = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setText(value ?? "");
|
setText(value ?? "");
|
||||||
@@ -74,19 +92,37 @@ export function Combobox({
|
|||||||
return all.filter((x) => normalize(x.label).includes(q));
|
return all.filter((x) => normalize(x.label).includes(q));
|
||||||
}, [all, q]);
|
}, [all, q]);
|
||||||
|
|
||||||
const showAdd =
|
// آیتمهای pinشده همیشه در ابتدای فهرست میمانند و با جستجو حذف نمیشوند؛
|
||||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام میشود.
|
||||||
|
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
|
||||||
|
const pin = pinnedIds ?? [];
|
||||||
|
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
|
||||||
|
const pinned = pin
|
||||||
|
.map((id) => all.find((x) => x.id === id))
|
||||||
|
.filter((x): x is ComboboxItem => x !== undefined);
|
||||||
|
const pinnedSet = new Set(pinned.map((x) => x.id));
|
||||||
|
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
|
||||||
|
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
|
||||||
|
}, [all, filtered, pinnedIds]);
|
||||||
|
|
||||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
const showAdd =
|
||||||
|
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
|
||||||
|
|
||||||
|
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||||
|
|
||||||
|
const listCount = visible.length + (showAdd ? 1 : 0);
|
||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
const pick = (it: ComboboxItem) => {
|
const pick = (it: ComboboxItem) => {
|
||||||
setText(it.label);
|
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||||
|
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||||
|
const nextText = it.id ? it.label : "";
|
||||||
|
setText(nextText);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
onChange?.(it);
|
onChange?.(it);
|
||||||
onInputChange?.(it.label);
|
onInputChange?.(nextText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCurrent = () => {
|
const addCurrent = () => {
|
||||||
@@ -111,9 +147,9 @@ export function Combobox({
|
|||||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (highlight >= 0 && highlight < filtered.length) {
|
if (highlight >= 0 && highlight < visible.length) {
|
||||||
pick(filtered[highlight]);
|
pick(visible[highlight]);
|
||||||
} else if (highlight === filtered.length && showAdd) {
|
} else if (highlight === visible.length && showAdd) {
|
||||||
addCurrent();
|
addCurrent();
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
@@ -125,8 +161,10 @@ export function Combobox({
|
|||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
showClear && "pe-8",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
@@ -140,24 +178,53 @@ export function Combobox({
|
|||||||
onInputChange?.(e.target.value);
|
onInputChange?.(e.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (openOnFocus) setOpen(true);
|
if (openOnFocus && !open) {
|
||||||
|
setOpen(true);
|
||||||
|
justOpenedByFocus.current = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیکهای بعدی تاگل میکنند
|
||||||
|
if (justOpenedByFocus.current) {
|
||||||
|
justOpenedByFocus.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen((o) => !o);
|
||||||
}}
|
}}
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
|
{showClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="پاک کردن"
|
||||||
|
title="پاک کردن"
|
||||||
|
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setText("");
|
||||||
|
setHighlight(-1);
|
||||||
|
setOpen(true);
|
||||||
|
onClear?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||||
{listCount === 0 && (
|
{listCount === 0 && (
|
||||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((it, i) => (
|
{visible.map((it, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={it.id + "|" + i}
|
key={it.id + "|" + i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||||
i === highlight && "bg-slate-bg",
|
i === highlight && "bg-slate-bg",
|
||||||
|
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault(); // حفظ فوکوس input
|
e.preventDefault(); // حفظ فوکوس input
|
||||||
@@ -182,9 +249,15 @@ export function Combobox({
|
|||||||
onMouseEnter={() => setHighlight(filtered.length)}
|
onMouseEnter={() => setHighlight(filtered.length)}
|
||||||
>
|
>
|
||||||
<span className="font-bold">+</span>
|
<span className="font-bold">+</span>
|
||||||
<span>
|
{addPrefix ? (
|
||||||
«<b>{text.trim()}</b>» {addSuffix}
|
<span>
|
||||||
</span>
|
{addPrefix} «<b>{text.trim()}</b>»
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
«<b>{text.trim()}</b>» {addSuffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
|
|||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
label?: string;
|
label?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز میشود
|
||||||
|
href?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : it.href ? (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={it.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||||
|
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
<span aria-hidden className="text-[10px] opacity-60">↗</span>
|
||||||
|
</a>
|
||||||
) : it.to ? (
|
) : it.to ? (
|
||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
|||||||
const setProjects = useAuthStore((s) => s.setProjects);
|
const setProjects = useAuthStore((s) => s.setProjects);
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
|
||||||
const isPm = user?.role === "pm";
|
// پنل انبار limited هم مثل سرپرست: پروژههای فعالش از عضویتها اعتبارسنجی میشود
|
||||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||||
|
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role !== "pm") {
|
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||||
|
if (!user || !scoped) {
|
||||||
setState("ok");
|
setState("ok");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
|||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user?.id, user?.role, isDev]);
|
}, [user?.id, user?.role, user?.panels, isDev]);
|
||||||
|
|
||||||
if (state === "loading") {
|
if (state === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۴ — کنترل روزانه (امروز)
|
||||||
|
// کارتهای KPI: بلیتهای خروج امروز، برگشت از سایت، ضایعات و پذیرش
|
||||||
|
// + فهرست دقیق نام تحویلدهندهها/انجامدهندهها + ریز عملیات امروز.
|
||||||
|
// هر ۶۰ ثانیه خودکار تازهسازی میشود (useDailyControl).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
PackageOpen,
|
||||||
|
TriangleAlert,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDailyControl } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
const TYPE_FA: Record<string, string> = {
|
||||||
|
WITHDRAWAL: "برداشت از انبار",
|
||||||
|
RETURN: "برگشت از سایت",
|
||||||
|
DAMAGE: "آسیبدیده",
|
||||||
|
WASTE: "اتلاف",
|
||||||
|
LOSS: "مفقودی",
|
||||||
|
PURCHASE: "پذیرش خرید",
|
||||||
|
TRANSFER_IN: "انتقال ورودی",
|
||||||
|
TRANSFER_OUT: "انتقال خروجی",
|
||||||
|
FOUND: "پیدا شده",
|
||||||
|
STOCK_COUNT: "انبارگردانی",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_STYLE: Record<string, string> = {
|
||||||
|
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
|
||||||
|
RETURN: "bg-amber-bg text-amber",
|
||||||
|
DAMAGE: "bg-red-bg text-red",
|
||||||
|
WASTE: "bg-red-bg text-red",
|
||||||
|
LOSS: "bg-red-bg text-red",
|
||||||
|
PURCHASE: "bg-green-bg text-green",
|
||||||
|
};
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
tickets,
|
||||||
|
qty,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
tickets: number;
|
||||||
|
qty: number;
|
||||||
|
tone: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
|
||||||
|
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
|
||||||
|
<Icon className="h-4.5 w-4.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-muted">{label}</div>
|
||||||
|
<div className="flex items-baseline gap-1.5 tabular-nums">
|
||||||
|
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
|
||||||
|
<span className="text-[10.5px] text-muted">بلیت</span>
|
||||||
|
<span className="text-[11px] text-muted">·</span>
|
||||||
|
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyControlPanel() {
|
||||||
|
const query = useDailyControl();
|
||||||
|
const d = query.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="کنترل روزانه — عملیات امروز"
|
||||||
|
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه بهروزرسانی میشود` : "هر دقیقه بهروزرسانی میشود"}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : !d ? (
|
||||||
|
<ChartEmpty message="دادهی امروز در دسترس نیست" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* KPI کارتها */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
icon={ArrowUpFromLine}
|
||||||
|
label="خروج به سایت (برداشت)"
|
||||||
|
tickets={d.outbound.tickets}
|
||||||
|
qty={d.outbound.qty}
|
||||||
|
tone="bg-blue-bg text-[#1d4ed8]"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={ArrowDownToLine}
|
||||||
|
label="برگشت از سایت"
|
||||||
|
tickets={d.returns.tickets}
|
||||||
|
qty={d.returns.qty}
|
||||||
|
tone="bg-amber-bg text-amber"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={TriangleAlert}
|
||||||
|
label="ضایعات / اتلاف / مفقودی"
|
||||||
|
tickets={d.waste.tickets}
|
||||||
|
qty={d.waste.qty}
|
||||||
|
tone="bg-red-bg text-red"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={PackageOpen}
|
||||||
|
label="پذیرش خرید (ورود)"
|
||||||
|
tickets={d.purchases.tickets}
|
||||||
|
qty={d.purchases.qty}
|
||||||
|
tone="bg-green-bg text-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-5">
|
||||||
|
{/* نام دقیق انجامدهندهها */}
|
||||||
|
<div className="rounded-xl border border-line p-3 lg:col-span-2">
|
||||||
|
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
|
||||||
|
<Users className="h-4 w-4 text-muted" />
|
||||||
|
تحویلدهندهها / انجامدهندههای امروز
|
||||||
|
</h3>
|
||||||
|
{d.performers.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-line">
|
||||||
|
{d.performers.map((p, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 py-1.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
|
||||||
|
{faNum(i + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
|
||||||
|
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
|
||||||
|
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ریز عملیات امروز */}
|
||||||
|
<div className="rounded-xl border border-line p-3 lg:col-span-3">
|
||||||
|
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
|
||||||
|
{d.items.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[12px] text-muted">هنوز جابهجاییای امروز ثبت نشده است</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
|
||||||
|
{d.items.map((it) => (
|
||||||
|
<li key={it.id} className="flex items-center gap-2 py-1.5">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 px-2 py-0 text-[10px]",
|
||||||
|
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate text-[12px] font-bold">{it.material}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||||
|
{fmtNum(it.quantity)} {it.unit_code}
|
||||||
|
</span>
|
||||||
|
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
|
||||||
|
{fmtDateTime(it.transaction_date)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// ============================================================
|
||||||
|
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||||
|
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||||
|
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||||
|
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||||
|
// متریال → مشخصات فنی موجودِ همان متریال
|
||||||
|
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||||
|
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||||
|
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||||
|
// سه فیلتر در یک ردیفِ وسطچین با عرض یکسان مینشینند (موبایل: تکستونی).
|
||||||
|
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Filter, X } from "lucide-react";
|
||||||
|
import { Combobox, type ComboboxItem } from "../Combobox";
|
||||||
|
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
// گزینهی «همه» — انتخابی که همان سطح و سطوح پاییندستش را پاک میکند.
|
||||||
|
// شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمیشود.
|
||||||
|
const ALL_OPTION_ID = "";
|
||||||
|
// این شناسه همیشه در ابتدای فهرست میماند و با جستجوی کاربر فیلتر نمیشود
|
||||||
|
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
||||||
|
|
||||||
|
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
||||||
|
const LABELS = {
|
||||||
|
allMaterials: "همهی متریالها",
|
||||||
|
allBrands: "همهی برندها",
|
||||||
|
allSpecs: "همهی مشخصات",
|
||||||
|
materialFirst: "ابتدا متریال را انتخاب کنید",
|
||||||
|
clearAll: "حذف فیلترها",
|
||||||
|
wholeOrg: "نمای کل سازمان",
|
||||||
|
cascade: "متریال ← برند ← مشخصات فنی",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
||||||
|
|
||||||
|
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
||||||
|
function specLabel(specs: Record<string, string> | null): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||||
|
const filterQuery = useDashboardFilterOptions();
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
|
||||||
|
// فهرست متریالها مستقیماً از کاتالوگ سراسری میآید؛ فقط یکتاسازی شناسهها
|
||||||
|
// انجام میشود تا هیچ گزینهی تکراری در فهرست نباشد.
|
||||||
|
const materials = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (filterQuery.data?.materials ?? []).filter((m) =>
|
||||||
|
seen.has(m.id) ? false : (seen.add(m.id), true),
|
||||||
|
);
|
||||||
|
}, [filterQuery.data]);
|
||||||
|
|
||||||
|
const selectedMaterial = useMemo(
|
||||||
|
() => materials.find((m) => m.id === state.materialId) ?? null,
|
||||||
|
[materials, state.materialId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||||
|
|
||||||
|
// انتخاب از فهرست؛ گزینهی «همه» همان سطح را پاک میکند (آبشار خودکار)
|
||||||
|
const pick =
|
||||||
|
(set: (id: string | null, label: string | null) => void) =>
|
||||||
|
(item: ComboboxItem) =>
|
||||||
|
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
|
||||||
|
|
||||||
|
// فهرست هر فیلد با گزینهی «همه» در ابتدا
|
||||||
|
const materialItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
|
||||||
|
[materials],
|
||||||
|
);
|
||||||
|
|
||||||
|
const brandItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [
|
||||||
|
allOption(LABELS.allBrands),
|
||||||
|
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
|
||||||
|
],
|
||||||
|
[selectedMaterial],
|
||||||
|
);
|
||||||
|
|
||||||
|
const specItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [
|
||||||
|
allOption(LABELS.allSpecs),
|
||||||
|
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
|
||||||
|
],
|
||||||
|
[selectedMaterial],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
|
||||||
|
aria-label="فیلتر سراسری متریال"
|
||||||
|
>
|
||||||
|
<div className="mb-2.5 flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
||||||
|
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
|
||||||
|
</div>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={state.reset}
|
||||||
|
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
|
||||||
|
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
|
||||||
|
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
|
||||||
|
{LABELS.clearAll}
|
||||||
|
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
|
||||||
|
{faNum(activeCount)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* سه فیلتر با عرض یکسان، وسطچین و متقارن؛ روی موبایل تکستونی میشود */}
|
||||||
|
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||||
|
{/* ۱ — متریال (سرآغاز آبشار) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.materialLabel ?? ""}
|
||||||
|
placeholder={LABELS.allMaterials}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={materials.length === 0}
|
||||||
|
items={materialItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
|
onClear={() => state.setMaterial(null, null)}
|
||||||
|
onChange={pick(state.setMaterial)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۲ — برند (فقط برندهای همان متریال) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.brandLabel ?? ""}
|
||||||
|
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||||
|
items={brandItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
|
onClear={() => state.setBrand(null, null)}
|
||||||
|
onChange={pick(state.setBrand)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.specsLabel ?? ""}
|
||||||
|
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||||
|
items={specItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
|
onClear={() => state.setSpecs(null, null)}
|
||||||
|
onChange={pick(state.setSpecs)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۲ — جدول موجودی راکد (سرمایهی خوابیده)
|
||||||
|
// ردیفهایی با موجودی مثبت که هیچ تراکنشی در بازهی رکود نداشتهاند.
|
||||||
|
// آستانهی ۶۰/۹۰ روز با چیپ انتخاب میشود؛ روزهای رکود رنگی است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Snowflake } from "lucide-react";
|
||||||
|
import { useDeadStock } from "../../hooks/useDashboardData";
|
||||||
|
import { fmtNum, faNum } from "../../lib/formatters";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
function specLabel(specs: Record<string, string>): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleSeverity(days: number | null): string {
|
||||||
|
if (days == null) return "text-muted";
|
||||||
|
if (days >= 90) return "text-red";
|
||||||
|
if (days >= 60) return "text-amber";
|
||||||
|
return "text-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeadStockTable() {
|
||||||
|
const [idleDays, setIdleDays] = useState<60 | 90>(60);
|
||||||
|
const query = useDeadStock(idleDays);
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
|
||||||
|
موجودی راکد — سرمایهی خوابیده
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازهی انتخابی نداشتهاند"
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([60, 90] as const).map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIdleDays(d)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
|
||||||
|
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(d)} روز و بیشتر
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<ChartEmpty message={`موجودی راکدی با آستانهی ${faNum(idleDays)} روز وجود ندارد`} />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead>متریال و مشخصات</TableHead>
|
||||||
|
<TableHead>برند</TableHead>
|
||||||
|
<TableHead>انبار / پروژه</TableHead>
|
||||||
|
<TableHead className="text-end">مقدار راکد</TableHead>
|
||||||
|
<TableHead className="text-end">روزهای رکود</TableHead>
|
||||||
|
<TableHead>آخرین فعالیت</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((it) => {
|
||||||
|
const specs = specLabel(it.specs);
|
||||||
|
return (
|
||||||
|
<TableRow key={it.inventory_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-[13px] font-bold">{it.material}</div>
|
||||||
|
{specs && <div className="text-[11px] text-muted">{specs}</div>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-[12px]">{it.warehouse}</div>
|
||||||
|
<div className="text-[10.5px] text-muted">{it.project}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end font-bold tabular-nums">
|
||||||
|
{fmtNum(it.quantity)}{" "}
|
||||||
|
<span className="text-[10.5px] font-normal text-muted">
|
||||||
|
{it.unit?.name_fa || it.unit?.code}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
|
||||||
|
{it.idle_days == null ? "—" : faNum(it.idle_days)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[11px] text-muted">
|
||||||
|
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۱ — ماتریس Drill-Down سلسلهمراتبی با نمودار ۱۰۰٪ استکشده
|
||||||
|
// • سطح ۰: محور X = متریالها، استکها = پروژهها
|
||||||
|
// • سطح ۱: محور X = پروژههای متریال انتخابشده، استکها = انبارها
|
||||||
|
// • قفل هوشمند: با فیلتر فعال انبار، drill-down غیرفعال میشود (کلیک بیاثر
|
||||||
|
// و نشانگر پیشفرض) چون اسکوپ انبار از قبل بسته شده است.
|
||||||
|
// • «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار؛ دامنههای سراسری
|
||||||
|
// پروژه/انبار (کنترلهای چندانتخابی) و متریال انتخابشده بهصورت تگ حذفپذیر.
|
||||||
|
// فیلتر سراسری «چه چیزی» (Zustand) دستنخورده میماند و متریال انتخابی با
|
||||||
|
// هر تغییر آن در برابر payload تازه اعتبارسنجی میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import type { XAxisTickContentProps } from "recharts";
|
||||||
|
import {
|
||||||
|
CheckSquare,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
MousePointerClick,
|
||||||
|
Package,
|
||||||
|
RotateCcw,
|
||||||
|
Square,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
|
||||||
|
import type { DrilldownProject } from "../../lib/types";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import {
|
||||||
|
ChartCard,
|
||||||
|
ChartEmpty,
|
||||||
|
ChartLoading,
|
||||||
|
} from "./chartShared";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type Level = 0 | 1; // ۰=متریالها (استک: پروژهها)، ۱=پروژهها (استک: انبارها)
|
||||||
|
|
||||||
|
// متریال انتخابشده برای drill-down — نام همان لحظهی کلیک ثبت میشود تا برچسب
|
||||||
|
// تگ breadcrumb حتی هنگام بارگذاری مجدد دادهها پایدار بماند.
|
||||||
|
interface DrillNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// یک گروه استک (پروژه در نمای کلی، انبار در نمای drill-down) — نام و رنگ از
|
||||||
|
// payload مشتق میشوند
|
||||||
|
interface StackGroup {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// یک میله روی محور X — سهم هر گروه (segments) از مجموع همان واحد.
|
||||||
|
// کمیت هر گروه علاوه بر segments، بهصورت پراپرتیِ مسطح و عددی هم روی خودِ شیء
|
||||||
|
// مینشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتیهای داده خوانده میشود.
|
||||||
|
interface StackedBar {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
unitLabel: string;
|
||||||
|
total: number;
|
||||||
|
segments: Record<string, number>;
|
||||||
|
[segmentKey: string]: number | string | Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// سطح ریشه + برچسبهای ثابت UI (یکجا تعریف میشوند تا در JSX تکرار نشوند)
|
||||||
|
const ROOT_LEVEL: Level = 0;
|
||||||
|
const DRILL_LEVEL: Level = 1;
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
root: "کل سازمان",
|
||||||
|
allProjects: "همهی پروژهها",
|
||||||
|
allWarehouses: "همهی انبارها",
|
||||||
|
widgetTitle: "",
|
||||||
|
projectScopeHint: "دامنهی پروژه — برای تغییر انتخابها کلیک کنید",
|
||||||
|
warehouseScopeHint: "دامنهی انبار — برای تغییر انتخابها کلیک کنید",
|
||||||
|
drillHint: "برای مشاهدهی سهم پروژهها و انبارها روی میلهها کلیک کنید",
|
||||||
|
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
|
||||||
|
removeMaterial: "حذف متریال انتخابشده و بازگشت به نمای متریالها",
|
||||||
|
totalLabel: "مجموع نمایش داده شده",
|
||||||
|
itemsLabel: "اقلام نمایش داده شده",
|
||||||
|
tooltipTotal: "مجموع",
|
||||||
|
tooltipBreakdown: "تفکیک بر اساس",
|
||||||
|
emptyChart: "موجودیای با این فیلترها یافت نشد",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// فرادادهی سطوح — برچسب محور X هر سطح (نامها از payload میآیند)
|
||||||
|
const LEVEL_META: Record<Level, { axisTitle: string }> = {
|
||||||
|
0: { axisTitle: "متریالها" },
|
||||||
|
1: { axisTitle: "پروژهها" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ——— تفکیک سگمنتها (استکها) ———
|
||||||
|
// نوع تفکیک زمینهآگاه است: نمای کلی بدون فیلتر پروژه → پروژهها؛ نمای
|
||||||
|
// drill-down متریال یا نمای دارای فیلتر پروژه → انبارها (تفکیک ریزتر).
|
||||||
|
type SegmentKind = "project" | "warehouse";
|
||||||
|
|
||||||
|
const SEGMENT_META: Record<SegmentKind, { label: string; plural: string }> = {
|
||||||
|
project: { label: "پروژه", plural: "پروژهها" },
|
||||||
|
warehouse: { label: "انبار", plural: "انبارها" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function segmentKindFor(level: Level, projectFilterActive: boolean): SegmentKind {
|
||||||
|
return level === DRILL_LEVEL || projectFilterActive ? "warehouse" : "project";
|
||||||
|
}
|
||||||
|
|
||||||
|
// نام کامل سگمنت انبار — انبارهای همنام در پروژههای مختلف با پیشوند پروژه از
|
||||||
|
// هم تفکیک میشوند («مجتمع مسکونی آرام - انبار شمالی»)
|
||||||
|
const SEGMENT_NAME_SEPARATOR = " - ";
|
||||||
|
const warehouseSegmentName = (projectName: string, warehouseName: string): string =>
|
||||||
|
`${projectName}${SEGMENT_NAME_SEPARATOR}${warehouseName}`;
|
||||||
|
|
||||||
|
// انبار همنام در «همان پروژه» هم ممکن است (دیتابیس محدودیت یکتایی روی
|
||||||
|
// (project_id, name) ندارد)؛ فقط در همان حالتِ برخورد، یک شمارهی ترتیب پایدار
|
||||||
|
// به انتهای نام اضافه میشود تا راهنما هیچوقت دو برچسب یکسان نداشته باشد.
|
||||||
|
function disambiguateNames(entries: { key: string; name: string }[]): Map<string, string> {
|
||||||
|
const keysByName = new Map<string, string[]>();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const keys = keysByName.get(entry.name) ?? [];
|
||||||
|
keys.push(entry.key);
|
||||||
|
keysByName.set(entry.name, keys);
|
||||||
|
});
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const twins = keysByName.get(entry.name) ?? [];
|
||||||
|
names.set(
|
||||||
|
entry.key,
|
||||||
|
twins.length > 1 ? `${entry.name} (${faNum(twins.indexOf(entry.key) + 1)})` : entry.name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// شناسهی استک در Recharts + سقف عرض میلهها بهازای هر سطح
|
||||||
|
const STACK_ID = "dashboard-stack";
|
||||||
|
const MAX_BAR_SIZE: Record<Level, number> = { 0: 110, 1: 64 };
|
||||||
|
const BAR_TOP_RADIUS: [number, number, number, number] = [6, 6, 0, 0];
|
||||||
|
|
||||||
|
// ——— رنگ گروههای استک ———
|
||||||
|
// رنگ هر گروه فقط از «ترتیب آن در payload» مشتق میشود (هیچ نگاشت دستی
|
||||||
|
// گروه→رنگ وجود ندارد)؛ چرخش طلایی تضمین میکند هر تعداد گروه، رنگهای
|
||||||
|
// بهاندازهی کافی متمایز بگیرند.
|
||||||
|
const GROUP_HUE_BASE = 232; // همخانواده با رنگ accent سیستم
|
||||||
|
const GROUP_HUE_STEP = 137.5; // چرخش طلایی
|
||||||
|
const GROUP_SATURATION = 58;
|
||||||
|
const GROUP_LIGHTNESS = 52;
|
||||||
|
|
||||||
|
function groupColor(index: number): string {
|
||||||
|
const hue = (GROUP_HUE_BASE + index * GROUP_HUE_STEP) % 360;
|
||||||
|
return `hsl(${hue.toFixed(1)} ${GROUP_SATURATION}% ${GROUP_LIGHTNESS}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// پسوند شمارش گزینههای چندانتخابی («… و ۲ پروژهی دیگر»)
|
||||||
|
const otherProjectsLabel = (count: number) => `${faNum(count)} پروژهی دیگر`;
|
||||||
|
const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`;
|
||||||
|
|
||||||
|
function crumbHint(level: Level, label: string): string {
|
||||||
|
if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاککردن دامنهها و متریال انتخابی`;
|
||||||
|
return `حذف «${label}» و بازگشت به نمای ${LEVEL_META[ROOT_LEVEL].axisTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// برچسب یک دامنهی چندانتخابی: «همهی …» / نام / «نام + پسوند شمارش»
|
||||||
|
function scopeLabel(
|
||||||
|
options: { id: string; name: string }[],
|
||||||
|
selectedIds: string[],
|
||||||
|
allLabel: string,
|
||||||
|
othersLabel: (count: number) => string,
|
||||||
|
): string {
|
||||||
|
if (selectedIds.length === 0) return allLabel;
|
||||||
|
const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name);
|
||||||
|
const first = names[0] ?? allLabel;
|
||||||
|
return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— چیدمان نمودار: میلهها و برچسبهای محور X ———
|
||||||
|
// هر میله یک اسلات عرض ثابت میگیرد تا میلهها پخش شوند و برچسبهای فارسی
|
||||||
|
// بلند روی هم نیفتند؛ نامهای بلندتر در چند خط شکسته میشوند.
|
||||||
|
const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
|
||||||
|
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
|
||||||
|
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
|
||||||
|
const AXIS_HEIGHT = 110; // ارتفاع ناحیهی برچسبهای محور X (px)
|
||||||
|
const AXIS_TICK_MARGIN = 15; // فاصلهی خط محور تا خط اول برچسب (px)
|
||||||
|
const LABEL_LINE_CHARS = 20; // سقف نویسهی هر خط برچسب
|
||||||
|
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
|
||||||
|
const LABEL_LINE_HEIGHT = 18; // فاصلهی خطوط برچسب (px)
|
||||||
|
|
||||||
|
// شکستن نام بلند به خطوط کوتاه — پایهی تیک محور X
|
||||||
|
function wrapAxisLabel(text: string): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const word of text.split(/\s+/).filter(Boolean)) {
|
||||||
|
// واژههای بسیار بلند هم به تکههای قابل نمایش شکسته میشوند
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) {
|
||||||
|
chunks.push(word.slice(i, i + LABEL_LINE_CHARS));
|
||||||
|
}
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) {
|
||||||
|
lines[lines.length - 1] = `${last} ${chunk}`;
|
||||||
|
} else {
|
||||||
|
lines.push(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length <= LABEL_MAX_LINES) return lines;
|
||||||
|
const head = lines.slice(0, LABEL_MAX_LINES - 1);
|
||||||
|
head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`);
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
// تیک سفارشی محور X — برچسب چندخطی با فاصلهی کافی (بدون همپوشانی).
|
||||||
|
// فاصلهی خط اول از محور از tickMargin خودِ XAxis میآید و شروع متن با
|
||||||
|
// dominantBaseline=hanging تراز میشود (مثل تیک پیشفرض Recharts).
|
||||||
|
function AxisTick({ x, y, payload }: XAxisTickContentProps) {
|
||||||
|
const xPos = typeof x === "number" ? x : Number(x);
|
||||||
|
const yPos = typeof y === "number" ? y : Number(y);
|
||||||
|
const lines = wrapAxisLabel(String(payload?.value ?? ""));
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={xPos}
|
||||||
|
y={yPos}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="hanging"
|
||||||
|
fill="#64748b"
|
||||||
|
fontSize={11}
|
||||||
|
fontFamily="Vazirmatn"
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<tspan key={i} x={xPos} dy={i === 0 ? 0 : LABEL_LINE_HEIGHT}>
|
||||||
|
{line}
|
||||||
|
</tspan>
|
||||||
|
))}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// تیک محور Y در نمای ۱۰۰٪ — کسر ۰..۱ به درصد فارسی
|
||||||
|
function percentTick(value: unknown): string {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? `${faNum(Math.round(n * 100))}٪` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// کمیتها هرگز بین واحدها جمع نمیشوند: برای هر میله «واحد غالب» (بیشترین
|
||||||
|
// مجموع) از خود payload انتخاب میشود و فقط کمیتهای همان واحد استک میشوند.
|
||||||
|
function pickDominantUnit(qtyByUnit: Record<string, number>): string | null {
|
||||||
|
let code: string | null = null;
|
||||||
|
let best = -Infinity;
|
||||||
|
for (const [unitCode, value] of Object.entries(qtyByUnit)) {
|
||||||
|
if (value > best) {
|
||||||
|
best = value;
|
||||||
|
code = unitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// کمیتها همیشه عدد خالص میمانند: هر مقدار غیرعددی/نامعتبر (رشته، NaN) از
|
||||||
|
// محاسبه حذف میشود تا میلهها هرگز بهخاطر دادهی نامعتبر نامرئی نشوند.
|
||||||
|
function numericValues(qtyByUnit: Record<string, number>): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const [unitCode, rawValue] of Object.entries(qtyByUnit)) {
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (Number.isFinite(value)) out[unitCode] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// تولتیپ سفارشی — کارت شیشهای مینیمال با سلسلهمراتب روشن:
|
||||||
|
// سربرگ (موجودیت میله + نوع تفکیک جاری)، بدنه (بهازای هر سگمنت: نقطهی رنگ
|
||||||
|
// داینامیک، نام کامل، کمیت و سهم درصدی) و پاصفحه (جمع میله).
|
||||||
|
// عرض کارت با محتوا رشد میکند (w-max) و فقط با سقف واکنشگرا محدود میشود؛
|
||||||
|
// متنهای بلند (عنوان/نام سگمنت) در همان سقف میشکنند تا هرگز از کادر بیرون
|
||||||
|
// نزنند، و گروههای عددی با whitespace-nowrap یکتکه میمانند تا اعداد و متن
|
||||||
|
// در حالت RTL بههم نریزند.
|
||||||
|
function CustomTooltip({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
payload,
|
||||||
|
stacks,
|
||||||
|
segmentKind,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
label?: string | number;
|
||||||
|
payload?: readonly { payload?: StackedBar }[];
|
||||||
|
stacks: StackGroup[];
|
||||||
|
segmentKind: SegmentKind;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const bar = payload[0]?.payload;
|
||||||
|
if (!bar) return null;
|
||||||
|
const rows = stacks
|
||||||
|
.map((stack) => ({ stack, value: bar.segments[stack.key] ?? 0 }))
|
||||||
|
.filter((row) => row.value > 0);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[320px] sm:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="whitespace-normal text-xs font-bold text-slate-800">
|
||||||
|
{String(label ?? bar.label)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500 sm:text-xs">
|
||||||
|
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* ردیف هر سگمنت: نام سمت راست، کمیت و درصد چسبیده به هم سمت چپ */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.stack.key} className="flex w-full items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ background: row.stack.color }}
|
||||||
|
/>
|
||||||
|
<span className="whitespace-normal text-[11px] font-medium text-slate-700 sm:text-xs">
|
||||||
|
{row.stack.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{fmtNum(row.value)} {bar.unitLabel}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap text-[11px] tabular-nums text-slate-700 sm:text-xs">
|
||||||
|
{faNum(Math.round((row.value / total) * 100))}٪
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* جمع میله — «مجموع: ۸۰۰ متر» کنار هم و از سمت راست */}
|
||||||
|
<div className="flex w-full items-center justify-start gap-1.5 text-xs font-semibold text-slate-600">
|
||||||
|
<span>{LABELS.tooltipTotal}:</span>
|
||||||
|
<span className="whitespace-nowrap tabular-nums">
|
||||||
|
{fmtNum(total)} {bar.unitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازهای میآید و
|
||||||
|
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
|
||||||
|
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
|
||||||
|
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
|
||||||
|
|
||||||
|
// --- MultiSelect Dropdown Component ---
|
||||||
|
// کنترل چندانتخابی دامنه (پروژه/انبار) — بهعنوان گرهی چندانتخابیِ breadcrumb
|
||||||
|
// رندر میشود؛ برچسبش بازتاب انتخابهای فعلی است و ردیف «همهی …» انتخابها را
|
||||||
|
// آزاد میکند. کلیکهای درون منو با stopPropagation جدا میشوند تا هرگز به
|
||||||
|
// handlerهای نمودار نرسند.
|
||||||
|
function MultiSelectDropdown({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
selectedIds,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
allLabel,
|
||||||
|
onClearAll,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: { id: string; name: string }[];
|
||||||
|
selectedIds: string[];
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
// با ارائهی این دو، ردیف «همهی …» در ابتدای فهرست ظاهر میشود
|
||||||
|
allLabel?: string;
|
||||||
|
onClearAll?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleItem = (id: string) => {
|
||||||
|
if (selectedIds.includes(id)) {
|
||||||
|
onChange(selectedIds.filter((x) => x !== id));
|
||||||
|
} else {
|
||||||
|
onChange([...selectedIds, id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "hover:bg-slate-bg hover:text-accent cursor-pointer",
|
||||||
|
selectedIds.length > 0 ? "text-accent" : "text-ink"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 max-h-60 w-56 overflow-auto rounded-lg border border-line bg-panel p-1 shadow-xl">
|
||||||
|
{allLabel && onClearAll && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
// رویداد منو هرگز نباید به کلیکهای نمودار برسد
|
||||||
|
event.stopPropagation();
|
||||||
|
onClearAll();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs font-bold text-accent transition-colors hover:bg-slate-bg"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{allLabel}</span>
|
||||||
|
</button>
|
||||||
|
<div className="my-1 border-t border-line" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="p-2 text-center text-xs text-muted">گزینهای یافت نشد</div>
|
||||||
|
) : (
|
||||||
|
options.map((opt) => {
|
||||||
|
const isSelected = selectedIds.includes(opt.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.id}
|
||||||
|
onClick={(event) => {
|
||||||
|
// رویداد انتخاب گزینه هم ایزوله میشود
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleItem(opt.id);
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-slate-bg"
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<CheckSquare className="h-4 w-4 text-accent" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-4 w-4 text-muted" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{opt.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrilldownMatrix() {
|
||||||
|
const query = useDrilldown();
|
||||||
|
|
||||||
|
// متریال انتخابشده (سطح ۱) — تنها حالت محلی نمودار؛ سطح از آن مشتق میشود
|
||||||
|
const [selectedMaterial, setSelectedMaterial] = useState<DrillNode | null>(null);
|
||||||
|
const level: Level = selectedMaterial ? DRILL_LEVEL : ROOT_LEVEL;
|
||||||
|
const materialId = selectedMaterial?.id ?? null;
|
||||||
|
|
||||||
|
// فیلترهای دستی (چندگزینهای پروژه/انبار) — وابسته به payload جاری
|
||||||
|
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||||
|
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// تفکیک جاری نمودار: نمای کلی بدون فیلتر پروژه → پروژهها؛ نمای drill-down یا
|
||||||
|
// نمای دارای فیلتر پروژه → انبارها
|
||||||
|
const segmentKind = segmentKindFor(level, selectedProjectIds.length > 0);
|
||||||
|
|
||||||
|
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
|
||||||
|
const plotRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [plotWidth, setPlotWidth] = useState(0);
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
|
||||||
|
// گزینههای آبشاری از دل payload ساخته میشوند
|
||||||
|
const projectOptions = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
data.materials.forEach((m) => {
|
||||||
|
m.projects.forEach((p) => {
|
||||||
|
map.set(p.id, p.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const warehouseOptions = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
data.materials.forEach((m) => {
|
||||||
|
m.projects.forEach((p) => {
|
||||||
|
if (selectedProjectIds.length === 0 || selectedProjectIds.includes(p.id)) {
|
||||||
|
p.warehouses.forEach((w) => {
|
||||||
|
map.set(w.id, w.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
|
}, [data, selectedProjectIds]);
|
||||||
|
|
||||||
|
// ——— همگامسازی با فیلتر سراسری (Zustand) ———
|
||||||
|
// فیلتر سراسری، payload را زیر پای حالت محلی عوض میکند؛ بهجای پاککردن
|
||||||
|
// کورکورانهی انتخابها، حالت محلی را با دادهی تازه اعتبارسنجی میکنیم تا نه
|
||||||
|
// در سطح کهنه گیر کنیم و نه بیدلیل به عقب بپریم.
|
||||||
|
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
|
||||||
|
|
||||||
|
// ۱) انتخابهای وابسته به payload (پروژه/انبار) نباید به شناسههایی اشاره کنند
|
||||||
|
// که در نتیجهی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپهای فعال میبینیم.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
const validProjectIds = new Set(projectOptions.map((o) => o.id));
|
||||||
|
setSelectedProjectIds((ids) =>
|
||||||
|
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
|
||||||
|
);
|
||||||
|
const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id));
|
||||||
|
setSelectedWarehouseIds((ids) =>
|
||||||
|
ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)),
|
||||||
|
);
|
||||||
|
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
|
||||||
|
|
||||||
|
// ۲) اگر متریال انتخابشده از payload حذف شده باشد، نمودار به نمای متریالها
|
||||||
|
// برمیگردد؛ انتخاب معتبر دستنخورده میماند.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !selectedMaterial) return;
|
||||||
|
if (!data.materials.some((m) => m.id === selectedMaterial.id)) {
|
||||||
|
setSelectedMaterial(null);
|
||||||
|
}
|
||||||
|
}, [globalFilterKey, data, selectedMaterial]);
|
||||||
|
|
||||||
|
// ——— اندازهگیری عرض کانتینر نمودار ———
|
||||||
|
// عرض صریح BarChart از همین مقدار و تعداد میلهها حساب میشود؛ کانتینر
|
||||||
|
// اسکرولدار است، پس با هر تغییر اندازهی پنجره/کارت دوباره اندازهگیری میشود.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = plotRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setPlotWidth(el.clientWidth);
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ——— دادهی نمودار ۱۰۰٪ استکشده ———
|
||||||
|
// محورها، سگمنتها (پروژه/انبار بر اساس segmentKind)، نامهای کامل، واحدها
|
||||||
|
// و رنگها همه از payload مشتق میشوند؛ هیچ نگاشت دستی در کد وجود ندارد.
|
||||||
|
const { bars, stacks, unitLabels } = useMemo(() => {
|
||||||
|
const empty = {
|
||||||
|
bars: [] as StackedBar[],
|
||||||
|
stacks: [] as StackGroup[],
|
||||||
|
unitLabels: [] as string[],
|
||||||
|
};
|
||||||
|
if (!data) return empty;
|
||||||
|
|
||||||
|
const matchesProject = (pid: string) =>
|
||||||
|
selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
|
||||||
|
const matchesWarehouse = (wid: string) =>
|
||||||
|
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
|
||||||
|
|
||||||
|
interface GroupAcc {
|
||||||
|
name: string;
|
||||||
|
byUnit: Record<string, number>;
|
||||||
|
}
|
||||||
|
const barsAcc = new Map<
|
||||||
|
string,
|
||||||
|
{ id: string; label: string; ownUnits: Record<string, number>; groups: Map<string, GroupAcc> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// هر میله حتی بدون گروه هم ساخته میشود تا برچسبش روی محور بماند
|
||||||
|
// (متریالهای کاتالوگی با موجودی صفر هم روی محور دیده میشوند).
|
||||||
|
const ensureBar = (id: string, label: string, ownUnits: Record<string, number>) => {
|
||||||
|
let bar = barsAcc.get(id);
|
||||||
|
if (!bar) {
|
||||||
|
bar = { id, label, ownUnits: numericValues(ownUnits), groups: new Map() };
|
||||||
|
barsAcc.set(id, bar);
|
||||||
|
}
|
||||||
|
return bar;
|
||||||
|
};
|
||||||
|
const addGroup = (
|
||||||
|
bar: { groups: Map<string, GroupAcc> },
|
||||||
|
groupId: string,
|
||||||
|
groupName: string,
|
||||||
|
qtyByUnit: Record<string, number>,
|
||||||
|
) => {
|
||||||
|
let group = bar.groups.get(groupId);
|
||||||
|
if (!group) {
|
||||||
|
group = { name: groupName, byUnit: {} };
|
||||||
|
bar.groups.set(groupId, group);
|
||||||
|
}
|
||||||
|
for (const [unitCode, value] of Object.entries(numericValues(qtyByUnit))) {
|
||||||
|
group.byUnit[unitCode] = (group.byUnit[unitCode] ?? 0) + value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// میلهها و سگمنتها با یک الگوی واحد ساخته میشوند: میله = متریال (نمای
|
||||||
|
// کلی) یا پروژه (نمای drill-down)، و سگمنت بر اساس تفکیک زمینهآگاه
|
||||||
|
// (segmentKind) پروژه یا انبار است.
|
||||||
|
const addBar = (
|
||||||
|
barId: string,
|
||||||
|
barLabel: string,
|
||||||
|
ownUnits: Record<string, number>,
|
||||||
|
projects: DrilldownProject[],
|
||||||
|
) => {
|
||||||
|
const bar = ensureBar(barId, barLabel, ownUnits);
|
||||||
|
projects.forEach((proj) => {
|
||||||
|
if (!matchesProject(proj.id)) return;
|
||||||
|
proj.warehouses.forEach((wh) => {
|
||||||
|
if (!matchesWarehouse(wh.id)) return;
|
||||||
|
if (segmentKind === "project") {
|
||||||
|
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
|
||||||
|
} else {
|
||||||
|
// سگمنت انبار: نام کامل «پروژه - انبار» تا انبارهای همنام در
|
||||||
|
// پروژههای مختلف در راهنما و تولتیپ قابل تفکیک باشند
|
||||||
|
addGroup(bar, wh.id, warehouseSegmentName(proj.name, wh.name), wh.qty_by_unit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (level === ROOT_LEVEL) {
|
||||||
|
// میله = متریال، سگمنت = پروژه (نمای کلی) یا انبار (وقتی فیلتر پروژه فعال است)
|
||||||
|
data.materials.forEach((mat) => addBar(mat.id, mat.name, mat.qty_by_unit, mat.projects));
|
||||||
|
} else {
|
||||||
|
// میله = پروژهی متریال انتخابشده، سگمنت = انبار
|
||||||
|
const mat = data.materials.find((m) => m.id === materialId);
|
||||||
|
mat?.projects.forEach((proj) => addBar(proj.id, proj.name, proj.qty_by_unit, [proj]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNames = new Map<string, string>();
|
||||||
|
const bars: StackedBar[] = [];
|
||||||
|
for (const bar of barsAcc.values()) {
|
||||||
|
// واحد غالب میله: از مجموع گروهها و در نبود گروه، از واحد خودِ موجودیت
|
||||||
|
const unitTotals: Record<string, number> = { ...bar.ownUnits };
|
||||||
|
for (const group of bar.groups.values()) {
|
||||||
|
for (const [unitCode, value] of Object.entries(group.byUnit)) {
|
||||||
|
unitTotals[unitCode] = (unitTotals[unitCode] ?? 0) + value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unitCode = pickDominantUnit(unitTotals);
|
||||||
|
const segments: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const [groupId, group] of bar.groups) {
|
||||||
|
const value = unitCode ? group.byUnit[unitCode] ?? 0 : 0;
|
||||||
|
segments[groupId] = value;
|
||||||
|
total += value;
|
||||||
|
groupNames.set(groupId, group.name);
|
||||||
|
}
|
||||||
|
bars.push({
|
||||||
|
id: bar.id,
|
||||||
|
label: bar.label,
|
||||||
|
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
|
||||||
|
total,
|
||||||
|
segments,
|
||||||
|
// مسطحسازی برای Recharts: هر <Bar dataKey={stack.key}> مقدار عددی را
|
||||||
|
// مستقیماً از سطح پراپرتیهای همین شیء میخواند
|
||||||
|
...segments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bars.sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
// فهرست استکها = اجتماع گروههای حاضر؛ ترتیب (و بنابراین رنگها) از مجموع
|
||||||
|
// کمیتها مشتق میشود و در حالت تساوی با نام، پایدار میماند.
|
||||||
|
const stackTotals = new Map<string, number>();
|
||||||
|
for (const bar of bars) {
|
||||||
|
for (const [groupId, value] of Object.entries(bar.segments)) {
|
||||||
|
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orderedKeys = [...stackTotals.entries()]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b[1] - a[1] ||
|
||||||
|
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
|
||||||
|
)
|
||||||
|
.map(([key]) => key);
|
||||||
|
// نامهای تکراری (انبار همنامِ همان پروژه) پیش از ساخت راهنما تفکیک میشوند
|
||||||
|
const uniqueNames = disambiguateNames(
|
||||||
|
orderedKeys.map((key) => ({ key, name: groupNames.get(key) ?? key })),
|
||||||
|
);
|
||||||
|
const stacks: StackGroup[] = orderedKeys.map((key, index) => ({
|
||||||
|
key,
|
||||||
|
name: uniqueNames.get(key) ?? groupNames.get(key) ?? key,
|
||||||
|
color: groupColor(index),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
bars,
|
||||||
|
stacks,
|
||||||
|
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
|
||||||
|
};
|
||||||
|
}, [data, level, materialId, segmentKind, selectedProjectIds, selectedWarehouseIds]);
|
||||||
|
|
||||||
|
// عرض صریح نمودار: هر میله یک اسلات ثابت میگیرد تا میلهها پخش شوند و
|
||||||
|
// برچسبهای محور X فاصلهی کافی داشته باشند؛ هرگز باریکتر از عرض واقعی
|
||||||
|
// کانتینر (و کف تعیینشده) نمیشود.
|
||||||
|
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
|
||||||
|
|
||||||
|
// ——— قفل هوشمند drill-down ———
|
||||||
|
// اگر کاربر انبار(های) مشخصی را در دامنهی سراسری فیلتر کرده باشد، عمیقتر
|
||||||
|
// رفتن روی نمودار بیمعناست؛ کلیک بیاثر و نشانگر پیشفرض میشود.
|
||||||
|
const drillLocked = selectedWarehouseIds.length > 0;
|
||||||
|
|
||||||
|
const handleBarClick = (entry: unknown) => {
|
||||||
|
// قفل فعال → هیچ پرشی انجام نمیشود
|
||||||
|
if (drillLocked) return;
|
||||||
|
// سطح پروژهها، سطح پایانی است؛ فقط از نمای متریالها drill میشویم
|
||||||
|
if (level !== ROOT_LEVEL) return;
|
||||||
|
const bar = (entry as { payload?: StackedBar })?.payload;
|
||||||
|
if (!bar) return;
|
||||||
|
setSelectedMaterial({ id: bar.id, name: bar.label });
|
||||||
|
};
|
||||||
|
|
||||||
|
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک میشوند
|
||||||
|
const resetToRoot = () => {
|
||||||
|
setSelectedMaterial(null);
|
||||||
|
setSelectedProjectIds([]);
|
||||||
|
setSelectedWarehouseIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// حذف تگ متریال — فقط حالت محلی نمودار صفر میشود و دامنههای سراسری دستنخورده میمانند
|
||||||
|
const clearMaterial = () => setSelectedMaterial(null);
|
||||||
|
|
||||||
|
// دامنهی پروژه فقط دامنهی انبارِ زیرمجموعهاش را آزاد میکند
|
||||||
|
const handleProjectScopeChange = (ids: string[]) => {
|
||||||
|
setSelectedProjectIds(ids);
|
||||||
|
if (ids.length === 0) setSelectedWarehouseIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ردیف «همهی پروژهها» در منوی گره: دامنهی پروژه و دامنهی عمیقترش (انبار) آزاد میشود
|
||||||
|
const clearProjectScope = () => {
|
||||||
|
setSelectedProjectIds([]);
|
||||||
|
setSelectedWarehouseIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// انبار ریزترین دامنه است: فقط خودش آزاد میشود و قفل drill-down برداشته میشود
|
||||||
|
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
|
||||||
|
|
||||||
|
const projectScopeLabel = () =>
|
||||||
|
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
|
||||||
|
const warehouseScopeLabel = () =>
|
||||||
|
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
|
||||||
|
|
||||||
|
// راهنمای تعامل با نمودار — هماهنگ با سطح جاری و قفل هوشمند
|
||||||
|
const drillHint = (): string => {
|
||||||
|
if (level !== ROOT_LEVEL) return "";
|
||||||
|
return drillLocked ? LABELS.lockedHint : LABELS.drillHint;
|
||||||
|
};
|
||||||
|
|
||||||
|
// جمع زدن کمیتهای با واحدهای متفاوت بیمعناست؛ در آن حالت تعداد اقلام میآید
|
||||||
|
const totalText =
|
||||||
|
unitLabels.length === 1
|
||||||
|
? `${LABELS.totalLabel}: ${fmtNum(bars.reduce((sum, bar) => sum + bar.total, 0))} ${unitLabels[0]}`
|
||||||
|
: `${LABELS.itemsLabel}: ${faNum(bars.length)}`;
|
||||||
|
|
||||||
|
// ظاهر مشترک گرههای کلیکی مسیر (گرهی جاری: برجسته؛ گرههای عقبرونده: قابل کلیک)
|
||||||
|
const crumbClass = (isCurrent: boolean) =>
|
||||||
|
cn(
|
||||||
|
"max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]",
|
||||||
|
isCurrent
|
||||||
|
? "bg-accent-bg text-accent hover:bg-accent-bg/70"
|
||||||
|
: "text-muted hover:bg-slate-bg hover:text-ink",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title={LABELS.widgetTitle}>
|
||||||
|
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
|
||||||
|
گرههای دامنه (پروژه/انبار) همان فیلترهای سراسریاند و متریال انتخابشده
|
||||||
|
بهصورت تگ حذفپذیر میآید؛ ✕ و ریشه هر دو انتخاب متریال را صفر میکنند. */}
|
||||||
|
<nav
|
||||||
|
dir="rtl"
|
||||||
|
aria-label="مسیر پیمایش گزارش"
|
||||||
|
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
|
||||||
|
>
|
||||||
|
{/* ریشه — بازگشت کامل به کل سازمان */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetToRoot}
|
||||||
|
aria-current={level === ROOT_LEVEL ? "page" : undefined}
|
||||||
|
title={crumbHint(ROOT_LEVEL, LABELS.root)}
|
||||||
|
className={crumbClass(level === ROOT_LEVEL)}
|
||||||
|
>
|
||||||
|
{LABELS.root}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* گرهی دامنهی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخابها */}
|
||||||
|
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label={projectScopeLabel()}
|
||||||
|
options={projectOptions}
|
||||||
|
selectedIds={selectedProjectIds}
|
||||||
|
onChange={handleProjectScopeChange}
|
||||||
|
allLabel={LABELS.allProjects}
|
||||||
|
onClearAll={clearProjectScope}
|
||||||
|
title={LABELS.projectScopeHint}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* گرهی دامنهی انبار — فقط وقتی دامنهی پروژه/انبار فعال است */}
|
||||||
|
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
|
||||||
|
<>
|
||||||
|
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label={warehouseScopeLabel()}
|
||||||
|
options={warehouseOptions}
|
||||||
|
selectedIds={selectedWarehouseIds}
|
||||||
|
onChange={setSelectedWarehouseIds}
|
||||||
|
allLabel={LABELS.allWarehouses}
|
||||||
|
onClearAll={clearWarehouseScope}
|
||||||
|
title={LABELS.warehouseScopeHint}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* تگ متریال انتخابشده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف میکنند */}
|
||||||
|
{selectedMaterial && (
|
||||||
|
<>
|
||||||
|
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearMaterial}
|
||||||
|
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
|
||||||
|
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
// ✕ نباید به handler بدنهی تگ برسد
|
||||||
|
event.stopPropagation();
|
||||||
|
clearMaterial();
|
||||||
|
}}
|
||||||
|
aria-label={LABELS.removeMaterial}
|
||||||
|
title={LABELS.removeMaterial}
|
||||||
|
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
|
||||||
|
>
|
||||||
|
<X aria-hidden className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ms-auto text-[11px] tabular-nums text-muted">{totalText}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* میلههای صفر عمداً رندر میشوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط
|
||||||
|
وقتی است که هیچ میلهای با فیلترها همخوانی نداشته باشد */}
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : !bars.length ? (
|
||||||
|
<ChartEmpty message={LABELS.emptyChart} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* کانتینر اسکرولدار — عرض صریح نمودار از chartWidth میآید تا
|
||||||
|
میلهها پخش شوند و برچسبهای فاصلهی کافی داشته باشند.
|
||||||
|
ریشهی حلقهی مشکی فوکوس: Recharts لایههای z-index را بهصورت
|
||||||
|
<g tabindex="-1" class="recharts-zIndex-layer_*"> رندر میکند
|
||||||
|
(ZIndexPortal.js) و آن g کل ناحیهی نمودار را میپوشاند؛ پس با
|
||||||
|
کلیک، فوکوس روی همین g مینشیند و مرورگر حلقهی پیشفرض را دور کل
|
||||||
|
نمودار میکشد. چون outline ارثبری نمیشود، مهار آن فقط با
|
||||||
|
سلکتور فرزند (g/سایر عناصر داخل نمودار) ممکن است. */}
|
||||||
|
<div
|
||||||
|
ref={plotRef}
|
||||||
|
dir="ltr"
|
||||||
|
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
|
||||||
|
style={{ width: '100%', height: CHART_HEIGHT }}
|
||||||
|
>
|
||||||
|
{/* بدون لایهی دسترسپذیری، Recharts روی خود SVG دیگر tabIndex
|
||||||
|
نمیگذارد؛ ولی لایههای z-index داخلیاش (g[tabindex="-1"]) هنوز
|
||||||
|
فوکوسپذیرند و همانها منبع حلقهی فوکوساند — که با سلکتورهای
|
||||||
|
فرزند روی wrapper مهار شدهاند. */}
|
||||||
|
<BarChart
|
||||||
|
width={chartWidth}
|
||||||
|
height={CHART_HEIGHT}
|
||||||
|
data={bars}
|
||||||
|
stackOffset="expand"
|
||||||
|
accessibilityLayer={false}
|
||||||
|
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={AxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e9f0" }}
|
||||||
|
interval={0}
|
||||||
|
height={AXIS_HEIGHT}
|
||||||
|
tickMargin={AXIS_TICK_MARGIN}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={percentTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={56}
|
||||||
|
domain={[0, 1]}
|
||||||
|
tickCount={5}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||||
|
content={(props) => (
|
||||||
|
<CustomTooltip {...props} stacks={stacks} segmentKind={segmentKind} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشههای گرد فقط
|
||||||
|
برای بالاترین گروه (آخرین Bar) اعمال میشود */}
|
||||||
|
{stacks.map((stack, index) => (
|
||||||
|
<Bar
|
||||||
|
key={stack.key}
|
||||||
|
dataKey={stack.key}
|
||||||
|
stackId={STACK_ID}
|
||||||
|
fill={stack.color}
|
||||||
|
radius={index === stacks.length - 1 ? BAR_TOP_RADIUS : 0}
|
||||||
|
maxBarSize={MAX_BAR_SIZE[level]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
onClick={handleBarClick}
|
||||||
|
className={cn(
|
||||||
|
"transition-opacity",
|
||||||
|
drillLocked ? "cursor-default" : "cursor-pointer hover:opacity-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* راهنمای رنگها — از همان سگمنتهای payload ساخته میشود و نام
|
||||||
|
انبارها بهصورت کامل «پروژه - انبار» نمایش داده میشود */}
|
||||||
|
{stacks.length > 0 && (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
<span className="font-bold">
|
||||||
|
{LEVEL_META[level].axisTitle} × {SEGMENT_META[segmentKind].plural}:
|
||||||
|
</span>
|
||||||
|
{stacks.map((stack) => (
|
||||||
|
<span key={stack.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: stack.color }} />
|
||||||
|
<span className="max-w-[16rem] truncate" title={stack.name}>
|
||||||
|
{stack.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{drillHint() && (
|
||||||
|
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
|
||||||
|
<MousePointerClick className="h-3.5 w-3.5" />
|
||||||
|
{drillHint()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تکمتریالی)
|
||||||
|
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||||
|
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||||
|
// روی مسیر واقعی مانده مینشینند، پس ستون دقیقاً پلهی خط stepAfter را میسازد.
|
||||||
|
//
|
||||||
|
// قواعد کسبوکار:
|
||||||
|
// • جمعزدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بیمعناست؛ پس
|
||||||
|
// بدون انتخاب متریال، نمودار رندر نمیشود و بهجایش انتخاب سریع متریال میآید.
|
||||||
|
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) میتواند چند «گروه» (مشخصات + برند)
|
||||||
|
// داشته باشد؛ هر گروه یک پلهی روشنتر از خانوادهی رنگ همان دسته میگیرد و
|
||||||
|
// سگمنتها یک ستون مشترک با جداکنندهی سفید ۱px میسازند.
|
||||||
|
// • همهی موجودیتها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق میشوند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||||
|
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
|
||||||
|
|
||||||
|
// ——— چیدمان ———
|
||||||
|
const FLOW_STACK_ID = "flow-waterfall";
|
||||||
|
const MAX_BAR_SIZE = 20;
|
||||||
|
const BAR_CATEGORY_GAP = "18%";
|
||||||
|
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
|
||||||
|
const OTHER_RESIDUAL_KEY = "__adjust__";
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
|
||||||
|
|
||||||
|
// خانوادهی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
|
||||||
|
const CATEGORY_META: Record<
|
||||||
|
FlowCategory,
|
||||||
|
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
|
||||||
|
> = {
|
||||||
|
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
|
||||||
|
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
|
||||||
|
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
|
||||||
|
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
|
||||||
|
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
|
||||||
|
};
|
||||||
|
|
||||||
|
// تنالیتهی گروههای یک دسته: هر گروه (مشخصات+برند) یک پله روشنتر
|
||||||
|
const SHADE_STEP_L = 9;
|
||||||
|
const SHADE_MAX_L = 78;
|
||||||
|
function categoryColor(category: FlowCategory, shadeIndex: number): string {
|
||||||
|
const { h, s, l } = CATEGORY_META[category].base;
|
||||||
|
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
|
||||||
|
return `hsl(${h} ${s}% ${lightness}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
title: "جریان روزانه موجودی",
|
||||||
|
needMaterial:
|
||||||
|
"برای مشاهدهی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
|
||||||
|
quickPick: "انتخاب سریع متریال",
|
||||||
|
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||||
|
opening: "مانده ابتدای روز",
|
||||||
|
net: "تغییر خالص روز",
|
||||||
|
closing: "مانده پایان روز",
|
||||||
|
unit: "واحد",
|
||||||
|
brand: "برند",
|
||||||
|
warehouse: "انبار",
|
||||||
|
noSpecs: "بدون مشخصه",
|
||||||
|
aggregate: "کل دسته",
|
||||||
|
adjustment: "تعدیل حساب",
|
||||||
|
shadeHint: "طیف روشنتر هر رنگ = برند/مشخصات دیگر در همان روز",
|
||||||
|
error: "دریافت دادهی جریان روزانه ناموفق بود",
|
||||||
|
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ——— مدل داده ———
|
||||||
|
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
|
||||||
|
|
||||||
|
interface CatItem {
|
||||||
|
key: string; // هویت گروه: مشخصات|برند
|
||||||
|
specLabel: string;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامتدار
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
brand: string | null;
|
||||||
|
quantity: number; // مجموع گروه (برای «سایر» علامتدار)
|
||||||
|
items: CatItem[]; // ریزردیفها برای تولتیپ
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatMap = Record<FlowCategory, CatGroup[]>;
|
||||||
|
|
||||||
|
interface FlowRow {
|
||||||
|
date: string;
|
||||||
|
opening: number;
|
||||||
|
closing: number;
|
||||||
|
net: number;
|
||||||
|
balance: number;
|
||||||
|
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
|
||||||
|
hasMovement: boolean;
|
||||||
|
// سگمنتها با کلیدهای مسطح seg_<category>_<shadeIndex>
|
||||||
|
[segmentKey: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
||||||
|
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||||
|
|
||||||
|
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «دادهای ثبت نشده» تلقّی شود
|
||||||
|
function FlowError() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
|
||||||
|
>
|
||||||
|
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
|
||||||
|
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
|
||||||
|
function specLabel(specs: Record<string, string> | null): string {
|
||||||
|
const parts = Object.entries(specs ?? {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— تولتیپ تحلیلی RTL ———
|
||||||
|
function FlowTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
unitLabel,
|
||||||
|
materialName,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: readonly { payload?: FlowRow }[];
|
||||||
|
unitLabel: string;
|
||||||
|
materialName: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const row = payload[0]?.payload;
|
||||||
|
if (!row) return null;
|
||||||
|
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
|
||||||
|
const netTone =
|
||||||
|
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[380px] sm:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{/* سربرگ: تاریخ + متریال + نشان واحد */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-slate-800">
|
||||||
|
{isoToJalaliStr(row.date) ?? row.date}
|
||||||
|
</span>
|
||||||
|
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
|
||||||
|
{unitLabel && (
|
||||||
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
|
||||||
|
{unitLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* مانده ابتدای روز */}
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(fmtNum(row.opening))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* دستهها + ریزردیف مشخصات/برند/انبار */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CATEGORY_ORDER.map((category) => {
|
||||||
|
const groups = row.groups[category] ?? [];
|
||||||
|
if (groups.length === 0) return null;
|
||||||
|
const total = round4(
|
||||||
|
groups.reduce((sum, group) => sum + group.quantity, 0) *
|
||||||
|
CATEGORY_META[category].sign,
|
||||||
|
);
|
||||||
|
if (total === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={category} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ background: categoryColor(category, 0) }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[category].label}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(signed(total))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key} className="flex flex-col">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
<span aria-hidden>↳</span>
|
||||||
|
<span>{item.specLabel}</span>
|
||||||
|
{item.brand && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.brand}: {item.brand}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.warehouse && (
|
||||||
|
<span className="text-slate-400">
|
||||||
|
({LABELS.warehouse}: {item.warehouse})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
|
||||||
|
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* جمعبندی */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
|
||||||
|
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
|
||||||
|
{withUnit(signed(row.net))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(fmtNum(row.closing))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— نرمالسازی یک روز: گروهبندی ریزردیفها بر اساس (دسته، مشخصات+برند) ———
|
||||||
|
function dayGroups(day: FlowDay): CatMap {
|
||||||
|
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
|
||||||
|
const insert = (category: FlowCategory, item: CatItem) => {
|
||||||
|
const existing = groups[category].find((group) => group.key === item.key);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = round4(existing.quantity + item.quantity);
|
||||||
|
existing.items.push(item);
|
||||||
|
} else {
|
||||||
|
groups[category].push({
|
||||||
|
key: item.key,
|
||||||
|
label: item.specLabel,
|
||||||
|
brand: item.brand,
|
||||||
|
quantity: item.quantity,
|
||||||
|
items: [item],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
|
||||||
|
if (breakdown.length > 0) {
|
||||||
|
breakdown.forEach((item) => {
|
||||||
|
// چهار سبد نمایشی همیشه مثبت نشان داده میشوند؛ «سایر» علامت دفتر را نگه میدارد
|
||||||
|
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
|
||||||
|
if (quantity === 0) return;
|
||||||
|
insert(item.category, {
|
||||||
|
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
|
||||||
|
specLabel: specLabel(item.specs),
|
||||||
|
brand: item.brand,
|
||||||
|
warehouse: item.warehouse,
|
||||||
|
quantity: round4(quantity),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// سازگاری عقبرو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته میشود
|
||||||
|
const aggregates: [FlowCategory, number][] = [
|
||||||
|
["inflow", day.inbound],
|
||||||
|
["returns", day.returns],
|
||||||
|
["siteOut", Math.max(0, day.outbound - day.waste)],
|
||||||
|
["waste", day.waste],
|
||||||
|
];
|
||||||
|
aggregates.forEach(([category, quantity]) => {
|
||||||
|
if (quantity > 0) {
|
||||||
|
insert(category, {
|
||||||
|
key: `${category}-all`,
|
||||||
|
specLabel: LABELS.aggregate,
|
||||||
|
brand: null,
|
||||||
|
warehouse: "",
|
||||||
|
quantity: round4(quantity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowChart() {
|
||||||
|
const filter = useDashboardFilter();
|
||||||
|
// بدون متریال، کوئری اصلاً اجرا نمیشود (جلوگیری از تجمیع واحدهای ناسازگار)
|
||||||
|
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
|
||||||
|
const options = useDashboardFilterOptions();
|
||||||
|
|
||||||
|
const materials = options.data?.materials ?? [];
|
||||||
|
const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
|
||||||
|
const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
|
||||||
|
const unitLabel =
|
||||||
|
query.data?.unit?.name_fa ??
|
||||||
|
query.data?.unit?.code ??
|
||||||
|
selectedMaterial?.unit?.name_fa ??
|
||||||
|
selectedMaterial?.unit?.code ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
// ——— همسازماندهی روزها + سگمنتهای شناور ———
|
||||||
|
const { rows, groupCounts, hasMovement } = useMemo(() => {
|
||||||
|
const days = query.data?.days ?? [];
|
||||||
|
let opening = query.data?.opening_balance ?? 0;
|
||||||
|
|
||||||
|
// گذر اول: نرمالسازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
|
||||||
|
const normalized = days.map((day) => {
|
||||||
|
const closing = day.balance;
|
||||||
|
const net = round4(closing - opening);
|
||||||
|
const groups = dayGroups(day);
|
||||||
|
const categorized = round4(
|
||||||
|
CATEGORY_ORDER.reduce((sum, category) => {
|
||||||
|
const categoryTotal = groups[category].reduce(
|
||||||
|
(groupSum, group) => groupSum + group.quantity,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return sum + categoryTotal * CATEGORY_META[category].sign;
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
const residual = round4(net - categorized);
|
||||||
|
if (residual !== 0) {
|
||||||
|
groups.other.push({
|
||||||
|
key: OTHER_RESIDUAL_KEY,
|
||||||
|
label: LABELS.adjustment,
|
||||||
|
brand: null,
|
||||||
|
quantity: residual,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const normalizedDay = { day, opening, closing, net, groups };
|
||||||
|
opening = closing;
|
||||||
|
return normalizedDay;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ترتیب گروهها (و در نتیجه پلهی رنگ) در کل بازه ثابت میماند
|
||||||
|
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
|
||||||
|
normalized.forEach(({ groups }) => {
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
|
||||||
|
groups[category].forEach((group) => {
|
||||||
|
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
|
||||||
|
});
|
||||||
|
totalByCategory.set(category, perCategory);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderByCategory = new Map<FlowCategory, string[]>();
|
||||||
|
const groupCounts: Record<FlowCategory, number> = {
|
||||||
|
inflow: 0,
|
||||||
|
returns: 0,
|
||||||
|
siteOut: 0,
|
||||||
|
waste: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([key]) => key);
|
||||||
|
orderByCategory.set(category, orderedKeys);
|
||||||
|
groupCounts[category] = orderedKeys.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده میگیرد
|
||||||
|
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
|
||||||
|
const row: FlowRow = {
|
||||||
|
date: day.date,
|
||||||
|
opening: dayOpening,
|
||||||
|
closing,
|
||||||
|
net,
|
||||||
|
balance: closing,
|
||||||
|
groups,
|
||||||
|
hasMovement: false,
|
||||||
|
};
|
||||||
|
let cursor = dayOpening;
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const orderedKeys = orderByCategory.get(category) ?? [];
|
||||||
|
const sign = CATEGORY_META[category].sign;
|
||||||
|
orderedKeys.forEach((key, index) => {
|
||||||
|
const group = groups[category].find((item) => item.key === key);
|
||||||
|
let range: Range = [cursor, cursor];
|
||||||
|
if (group && group.quantity !== 0) {
|
||||||
|
const from = cursor;
|
||||||
|
cursor = round4(cursor + group.quantity * sign);
|
||||||
|
range = [Math.min(from, cursor), Math.max(from, cursor)];
|
||||||
|
row.hasMovement = true;
|
||||||
|
}
|
||||||
|
row[`seg_${category}_${index}`] = range;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
groupCounts,
|
||||||
|
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
|
||||||
|
};
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
// ——— دامنهی محور Y با ۱۰٪ سرصفحه ———
|
||||||
|
const [domainMin, domainMax] = useMemo(() => {
|
||||||
|
const values: number[] = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
values.push(row.balance, row.opening, row.closing);
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const count = groupCounts[category] ?? 0;
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const range = row[`seg_${category}_${index}`] as Range | undefined;
|
||||||
|
if (range) values.push(range[0], range[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (values.length === 0) return [0, 1] as const;
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
||||||
|
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
||||||
|
}, [rows, groupCounts]);
|
||||||
|
|
||||||
|
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
|
||||||
|
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
|
||||||
|
|
||||||
|
// ——— دروازهی تکمتریالی: بدون متریال، نمودار رندر نمیشود ———
|
||||||
|
if (!filter.materialId) {
|
||||||
|
return (
|
||||||
|
<ChartCard title={LABELS.title}>
|
||||||
|
{options.isError ? (
|
||||||
|
<FlowError />
|
||||||
|
) : options.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
|
||||||
|
<Package aria-hidden className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
|
||||||
|
{materials.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
|
||||||
|
<div className="flex max-w-xl flex-wrap justify-center gap-2">
|
||||||
|
{materials.map((material) => {
|
||||||
|
const materialUnit =
|
||||||
|
material.unit?.name_fa ?? material.unit?.code ?? "";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={material.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => filter.setMaterial(material.id, material.name)}
|
||||||
|
title={material.name}
|
||||||
|
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
<span className="truncate">{material.name}</span>
|
||||||
|
{materialUnit && (
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
|
||||||
|
{materialUnit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={LABELS.title}
|
||||||
|
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : query.isError ? (
|
||||||
|
<FlowError />
|
||||||
|
) : !hasMovement ? (
|
||||||
|
<ChartEmpty message={LABELS.empty} />
|
||||||
|
) : (
|
||||||
|
<div dir="rtl" className="flex flex-col gap-1">
|
||||||
|
{/* راهنمای رنگ دستهها (بهجای Legend پیشفرض؛ سریهای تنالیته معنا ندارند) */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||||
|
{visibleCategories.map((category) => (
|
||||||
|
<span key={category} className="inline-flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ background: categoryColor(category, 0) }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[category].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasMultipleShades && (
|
||||||
|
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div dir="ltr" className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart
|
||||||
|
data={rows}
|
||||||
|
barCategoryGap={BAR_CATEGORY_GAP}
|
||||||
|
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
|
||||||
|
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e9f0" }}
|
||||||
|
minTickGap={18}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[domainMin, domainMax]}
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={faAxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={unitLabel ? 70 : 56}
|
||||||
|
label={
|
||||||
|
unitLabel
|
||||||
|
? {
|
||||||
|
value: unitLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: "insideLeft",
|
||||||
|
style: { fontSize: 10, fill: "#64748b" },
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
|
content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
|
||||||
|
/>
|
||||||
|
{/* سگمنتهای شناور: هر گروه (مشخصات+برند) یک پله از خانوادهی رنگ
|
||||||
|
دسته، با جداکنندهی سفید ۱px و یک ستون مشترک */}
|
||||||
|
{CATEGORY_ORDER.map((category) =>
|
||||||
|
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
|
||||||
|
<Bar
|
||||||
|
key={`${category}-${index}`}
|
||||||
|
dataKey={`seg_${category}_${index}`}
|
||||||
|
fill={categoryColor(category, index)}
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={1}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||||
|
<Line
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="balance"
|
||||||
|
name={LABELS.closing}
|
||||||
|
stroke="#0f172a"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۶ — شبکهی وضعیت سفارشهای در راه
|
||||||
|
// جدول کمی سفارشهای باز: متریال و تأمینکننده | سفارششده | رسیده
|
||||||
|
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
|
||||||
|
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Truck } from "lucide-react";
|
||||||
|
import { useInTransit } from "../../hooks/useDashboardData";
|
||||||
|
import type { InTransitRow } from "../../lib/types";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
const STATE_META: Record<
|
||||||
|
InTransitRow["state"],
|
||||||
|
{ label: string; cls: string; bar: string }
|
||||||
|
> = {
|
||||||
|
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
|
||||||
|
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
|
||||||
|
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function specLabel(specs: Record<string, string>): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: string): number {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InTransitGrid() {
|
||||||
|
const query = useInTransit();
|
||||||
|
const rows = query.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Truck className="h-4 w-4 text-muted" />
|
||||||
|
سفارشهای در راه — وضعیت تحویل
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
sub="اقلام پذیرفتهشدهی خرید تا رسیدن کامل به انبار"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<ChartEmpty message="سفارش بازی در جریان نیست" />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead>متریال / تأمینکننده</TableHead>
|
||||||
|
<TableHead className="text-end">سفارششده</TableHead>
|
||||||
|
<TableHead className="text-end">رسیده تاکنون</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
|
||||||
|
<TableHead>وضعیت</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const ordered = num(r.ordered_qty);
|
||||||
|
const received = num(r.received_qty);
|
||||||
|
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
|
||||||
|
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
|
||||||
|
const specs = specLabel(r.specs);
|
||||||
|
return (
|
||||||
|
<TableRow key={r.item_decision_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-[13px] font-bold">{r.material.name}</div>
|
||||||
|
<div className="text-[11px] text-muted">
|
||||||
|
{r.supplier.name}
|
||||||
|
{r.brand ? ` · ${r.brand.name}` : ""}
|
||||||
|
</div>
|
||||||
|
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
|
||||||
|
{r.project && (
|
||||||
|
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end font-bold tabular-nums">
|
||||||
|
{fmtNum(ordered)}
|
||||||
|
<div className="text-[10px] font-normal text-muted">
|
||||||
|
{r.unit?.name_fa || r.unit?.code}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end font-bold tabular-nums">
|
||||||
|
{fmtNum(received)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full", meta.bar)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
|
||||||
|
{faNum(pct)}٪
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۵ — KPI زمان تأمین (Lead Time)
|
||||||
|
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
|
||||||
|
// از /quotes/stats (میانگین arrived_date − accepted_date).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Timer } from "lucide-react";
|
||||||
|
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { ChartCard, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
export function LeadTimeKpiCard() {
|
||||||
|
const query = useQuoteStats();
|
||||||
|
const days = query.data?.average_lead_time_days;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
|
||||||
|
<Timer className="h-7 w-7" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
|
||||||
|
{days == null ? "—" : faNum(days)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] font-bold text-muted">روز</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-muted">
|
||||||
|
{days == null
|
||||||
|
? "هنوز سفارشی به مرحلهی رسیدن نرسیده است"
|
||||||
|
: `بر پایهی سفارشهای رسیدهشده به انبار`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۷ — قیف تدارکات
|
||||||
|
// تبدیل تجمعی: کل درخواستها (پیشنهادشده) → پذیرفتهشده → رسیده به انبار
|
||||||
|
// از /quotes/stats.funnel (شمارش تجمعی بر پایهی تاریخهای واقعی).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
fill: string;
|
||||||
|
conversion: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcurementFunnel() {
|
||||||
|
const query = useQuoteStats();
|
||||||
|
|
||||||
|
const steps: Step[] = useMemo(() => {
|
||||||
|
const f = query.data?.funnel;
|
||||||
|
if (!f) return [];
|
||||||
|
const offered = f.offered_total;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "کل درخواستها (پیشنهادشده)",
|
||||||
|
value: f.offered_total,
|
||||||
|
fill: CHART_COLORS.accent,
|
||||||
|
conversion: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "پذیرفتهشده",
|
||||||
|
value: f.accepted_total,
|
||||||
|
fill: CHART_COLORS.blue,
|
||||||
|
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "رسیده به انبار",
|
||||||
|
value: f.arrived_total,
|
||||||
|
fill: CHART_COLORS.green,
|
||||||
|
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
const total = steps[0]?.value ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="قیف تدارکات"
|
||||||
|
sub="تبدیل درخواستها به سفارش پذیرفتهشده و رسیدن کالا به انبار"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : total === 0 ? (
|
||||||
|
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
|
||||||
|
) : (
|
||||||
|
<div className="grid items-center gap-3 sm:grid-cols-5">
|
||||||
|
<div dir="ltr" className="h-56 sm:col-span-3">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<FunnelChart>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
labelFormatter={(l) => String(l)}
|
||||||
|
valueFormatter={(v) => `${fmtNum(v)} درخواست`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
|
||||||
|
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
|
||||||
|
<LabelList
|
||||||
|
dataKey="value"
|
||||||
|
position="right"
|
||||||
|
fill="#0f172a"
|
||||||
|
stroke="none"
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={700}
|
||||||
|
formatter={(v: unknown) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? fmtNum(n) : "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* نام مرحله داخل نوار */}
|
||||||
|
<LabelList
|
||||||
|
dataKey="name"
|
||||||
|
position="left"
|
||||||
|
fill="#ffffff"
|
||||||
|
stroke="none"
|
||||||
|
fontSize={11}
|
||||||
|
formatter={(v: unknown) => (v == null ? "" : String(v))}
|
||||||
|
/>
|
||||||
|
</Funnel>
|
||||||
|
</FunnelChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مراحل قیف + درصد تبدیل */}
|
||||||
|
<ul className="flex flex-col gap-2 sm:col-span-2">
|
||||||
|
{steps.map((s) => (
|
||||||
|
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
|
||||||
|
<span className="text-[12px] font-bold">{s.name}</span>
|
||||||
|
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
|
||||||
|
{fmtNum(s.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.conversion != null && (
|
||||||
|
<div className="mt-0.5 text-[10.5px] text-muted">
|
||||||
|
{faNum(s.conversion)}٪ از کل درخواستها
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۸ — تراکم پروژهها (Project Density)
|
||||||
|
// نمودار میلهای افقی: توزیع بار تدارکات روی پروژههای فعال —
|
||||||
|
// سفارشهای باز (ستهشده با پیشنهادهای باز).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useProcurementWorkload } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import {
|
||||||
|
CHART_COLORS,
|
||||||
|
ChartCard,
|
||||||
|
ChartEmpty,
|
||||||
|
ChartLoading,
|
||||||
|
ChartTooltip,
|
||||||
|
faAxisTick,
|
||||||
|
} from "./chartShared";
|
||||||
|
|
||||||
|
export function ProjectDensityChart() {
|
||||||
|
const query = useProcurementWorkload();
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const projects = query.data?.projects ?? [];
|
||||||
|
return projects
|
||||||
|
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
"سفارش باز (در راه)": p.open_orders,
|
||||||
|
"پیشنهاد باز": p.offered_quotes,
|
||||||
|
_ordered: p.ordered_qty,
|
||||||
|
_received: p.received_qty,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="تراکم بار تدارکات روی پروژهها"
|
||||||
|
sub="تعداد سفارشهای باز و پیشنهادهای در جریان به تفکیک پروژه"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<ChartEmpty message="بار تدارکاتی فعالی روی پروژهها وجود ندارد" />
|
||||||
|
) : (
|
||||||
|
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={rows}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
|
||||||
|
barCategoryGap="28%"
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={faAxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={150}
|
||||||
|
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
labelFormatter={(l) => String(l)}
|
||||||
|
valueFormatter={(v) => `${faNum(v)} مورد`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={26}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value: unknown) => (
|
||||||
|
<span dir="rtl" className="text-[11px] text-muted">
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="سفارش باز (در راه)"
|
||||||
|
stackId="load"
|
||||||
|
fill={CHART_COLORS.blue}
|
||||||
|
maxBarSize={22}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="پیشنهاد باز"
|
||||||
|
stackId="load"
|
||||||
|
fill={CHART_COLORS.amber}
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
maxBarSize={22}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
|
||||||
|
// همهی نمودارهای داشبورد از این قالب استفاده میکنند تا خروجی
|
||||||
|
// Recharts فارسی و هماهنگ با دیزاینسیستم (Tailwind @theme) باشد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { groupCommas } from "../../lib/numutil";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
// پالت معنایی نمودارها — همراستا با توکنهای @theme در globals.css
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
inbound: "#16a34a", // ورود = سبز
|
||||||
|
outbound: "#2563eb", // خروج = آبی
|
||||||
|
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایهی داخل ستون خروج)
|
||||||
|
returns: "#d97706", // برگشت از سایت = کهربایی
|
||||||
|
balance: "#0f172a", // مانده = خط سرمهای نقطهچین
|
||||||
|
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
|
||||||
|
amber: "#d97706",
|
||||||
|
green: "#16a34a",
|
||||||
|
red: "#dc2626",
|
||||||
|
blue: "#2563eb",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// عدد → رقم فارسی با جداکنندهی هزارگان (تیک محورها)
|
||||||
|
export function faAxisTick(value: unknown): string {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "";
|
||||||
|
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
|
||||||
|
return groupCommas(n, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
|
||||||
|
export function jaAxisDate(iso: unknown): string {
|
||||||
|
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
|
||||||
|
return j || String(iso ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نامهای فارسی سریها ———
|
||||||
|
interface TooltipEntry {
|
||||||
|
name?: string | number;
|
||||||
|
value?: string | number;
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string | number;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipEntry[];
|
||||||
|
label?: string | number;
|
||||||
|
labelFormatter?: (label: string | number) => ReactNode;
|
||||||
|
valueFormatter?: (value: number, name: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
|
||||||
|
if (!active || !payload || payload.length === 0) return null;
|
||||||
|
// ردیفهای صفر را از تولتیپ حذف میکنیم — شلوغیِ بیمعنا (اصل Power BI)
|
||||||
|
const rows = payload.filter((p) => {
|
||||||
|
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||||
|
return Number.isFinite(v) && v !== 0;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{label !== undefined && label !== "" && (
|
||||||
|
<div className="mb-1 text-[11px] font-bold text-muted">
|
||||||
|
{labelFormatter ? labelFormatter(label) : String(label)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{rows.map((p, i) => {
|
||||||
|
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||||
|
const name = String(p.name ?? p.dataKey ?? "");
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
|
||||||
|
<span className="text-muted">{name}</span>
|
||||||
|
<span className="ms-auto font-bold tabular-nums text-ink">
|
||||||
|
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— کارت نگهدارندهی ویجت — عنوان + توضیح + اکشن ———
|
||||||
|
export function ChartCard({
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: ReactNode;
|
||||||
|
sub?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
|
||||||
|
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
|
||||||
|
{sub && <p className="text-[11px] text-muted">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— حالت خالی/خطای ویجت ———
|
||||||
|
export function ChartEmpty({ message = "دادهای برای نمایش وجود ندارد" }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
|
||||||
|
در حال بارگذاری...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
|
||||||
|
export function UnitPicker({
|
||||||
|
units,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
totals,
|
||||||
|
}: {
|
||||||
|
units: Record<string, string>;
|
||||||
|
value: string;
|
||||||
|
onChange: (code: string) => void;
|
||||||
|
totals?: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
const codes = Object.keys(units);
|
||||||
|
if (codes.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{codes.map((code) => {
|
||||||
|
const total = totals?.[code];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(code)}
|
||||||
|
title={"نمایش موجودی به " + (units[code] || code)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
|
||||||
|
value === code
|
||||||
|
? "bg-accent text-white"
|
||||||
|
: "bg-slate-bg text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{units[code] || code}
|
||||||
|
{total != null && (
|
||||||
|
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
|
||||||
|
{fmtNum(total)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight, ClipboardList } from "lucide-react";
|
import { ChevronRight, ClipboardList } from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useAuthStore, type AuthUser } from "../../store/auth";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function userSubtitle(user: AuthUser | null | undefined): string {
|
||||||
|
if (!user) return "";
|
||||||
|
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
|
||||||
|
const username = user.username || user.casdoor_id || "-";
|
||||||
|
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
||||||
|
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
|
||||||
|
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,6 +35,10 @@ interface HeaderProps {
|
|||||||
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const userSub = userSubtitle(user);
|
||||||
|
const displaySub = sub !== undefined ? sub : userSub;
|
||||||
|
|
||||||
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
||||||
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
||||||
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
|||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
||||||
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
{displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
PackageSearch,
|
PackageSearch,
|
||||||
Scale,
|
Scale,
|
||||||
Truck,
|
Truck,
|
||||||
UsersRound,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
|
|||||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||||
];
|
];
|
||||||
|
|
||||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات (آینهی ناوبری بالای دسکتاپ)
|
// تبهای پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینهی ناوبری بالای دسکتاپ)
|
||||||
const ADMIN_INV_TABS: TabDef[] = [
|
const ADMIN_INV_TABS: TabDef[] = [
|
||||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||||
|
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
|
||||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||||
{ to: "/inventory/org", label: "سازماندهی", icon: UsersRound },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
||||||
@@ -81,17 +80,28 @@ export function MobileTabBar() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
if (pathname === "/login" || pathname === "/admin") return null;
|
if (pathname === "/login" || pathname === "/admin") return null;
|
||||||
|
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تببار خودش را دارد
|
||||||
|
if (pathname === "/requester" || pathname === "/panel/pm") return null;
|
||||||
|
// کاربر فقطِ تدارکات-limited: تبهای انبار/دفتر ندارد
|
||||||
|
const panels = useAuthStore.getState().user?.panels ?? {};
|
||||||
|
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
|
||||||
|
// پنل مدیریت پروژه: iframe تمامصفحهی PM است و خودش تببار داخلی دارد
|
||||||
|
if (pathname === "/panel/pm") return null;
|
||||||
const isPM = role === "pm";
|
const isPM = role === "pm";
|
||||||
|
const isSupervisor = role === "supervisor";
|
||||||
const isOffice = role === "admin" || role === "procurement_manager";
|
const isOffice = role === "admin" || role === "procurement_manager";
|
||||||
|
|
||||||
if (pathname.startsWith("/inventory")) {
|
if (pathname.startsWith("/inventory")) {
|
||||||
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
||||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// سرپرست پروژه به بخشهای تدارکات/دفتر دسترسی ندارد
|
||||||
|
if (isSupervisor) return null;
|
||||||
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
||||||
const onOfficePage =
|
const onOfficePage =
|
||||||
pathname === "/" ||
|
pathname === "/" ||
|
||||||
|
pathname === "/dashboard" ||
|
||||||
pathname === "/projects" ||
|
pathname === "/projects" ||
|
||||||
pathname === "/units" ||
|
pathname === "/units" ||
|
||||||
pathname.startsWith("/suppliers");
|
pathname.startsWith("/suppliers");
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
|
|||||||
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
||||||
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
||||||
export { useMaterials, useAddMaterial } from "./useMaterials";
|
export { useMaterials, useAddMaterial } from "./useMaterials";
|
||||||
export { useProjects, useAddProject } from "./useProjects";
|
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
|
||||||
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هوکهای TanStack Query برای داشبورد MVP
|
||||||
|
// همهی ویجتها مستقیم به API واقعی وصلاند — بدون هیچ دادهی ساختگی.
|
||||||
|
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
|
||||||
|
// تغییر فیلتر، فقط ویجتهای همان تب دوباره خوانده شوند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiV26 } from "../lib/api";
|
||||||
|
import type { DashboardFilterParams } from "../lib/api";
|
||||||
|
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
|
||||||
|
|
||||||
|
function useFilterParams(): DashboardFilterParams {
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
return dashboardFilterParams(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// گزینههای آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
|
||||||
|
export function useDashboardFilterOptions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "filters"],
|
||||||
|
queryFn: () => apiV26.dashboard.filters(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
|
||||||
|
export function useDrilldown() {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "drilldown", params],
|
||||||
|
queryFn: () => apiV26.dashboard.drilldown(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۲ — موجودی راکد (dead stock)
|
||||||
|
export function useDeadStock(idleDays: number) {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "dead-stock", idleDays, params],
|
||||||
|
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۳ — سری زمانی جریان روزانه
|
||||||
|
// فقط با متریال مشخص اجرا میشود: جمع چند متریال با واحدهای متفاوت بیمعناست.
|
||||||
|
export function useDailyFlow(dateFrom?: string, dateTo?: string, enabled = true) {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||||
|
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازهسازی میشود
|
||||||
|
export function useDailyControl(date?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "daily-control", date],
|
||||||
|
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
|
||||||
|
export function useQuoteStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "quote-stats"],
|
||||||
|
queryFn: () => apiV26.dashboard.quoteStats(),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
|
||||||
|
export function useInTransit() {
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
const params = dashboardFilterParams(state);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "in-transit", params],
|
||||||
|
queryFn: () =>
|
||||||
|
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۸ — بار تدارکات روی پروژهها
|
||||||
|
export function useProcurementWorkload() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "workload"],
|
||||||
|
queryFn: () => apiV26.dashboard.workload(),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,3 +21,15 @@ export function useAddProject() {
|
|||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeleteProject() {
|
||||||
|
const deleteProject = useAppStore((s) => s.deleteProject);
|
||||||
|
const invalidate = useInvalidateStore();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.projects.delete(id),
|
||||||
|
onMutate: (id) => deleteProject(id),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+234
-26
@@ -19,19 +19,38 @@ import { useAuthStore } from "../store/auth";
|
|||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
ConfirmedReceipt,
|
ConfirmedReceipt,
|
||||||
|
DashboardFilterOptions,
|
||||||
|
DailyControlData,
|
||||||
|
DeadStockItem,
|
||||||
|
DrilldownData,
|
||||||
|
FlowData,
|
||||||
|
InTransitRow,
|
||||||
InventoryRow,
|
InventoryRow,
|
||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
Material,
|
Material,
|
||||||
PendingArrival,
|
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
|
QuoteStats,
|
||||||
StandardConversion,
|
StandardConversion,
|
||||||
Store,
|
Store,
|
||||||
Supplier,
|
Supplier,
|
||||||
|
ProjectTransfer,
|
||||||
|
ProjectTransferCreateIn,
|
||||||
|
ProjectTransferReceiveIn,
|
||||||
|
ProjectTransferUpdateIn,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
|
WorkloadProject,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||||
|
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||||
|
export type DashboardFilterParams = {
|
||||||
|
material_id?: string | null;
|
||||||
|
brand_id?: string | null;
|
||||||
|
specs_hash?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -87,7 +106,7 @@ function handle401(path: string, res: Response): void {
|
|||||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||||
function activeProjectId(): string | null {
|
function activeProjectId(): string | null {
|
||||||
const { user } = useAuthStore.getState();
|
const { user } = useAuthStore.getState();
|
||||||
return user?.role === "pm" ? user.project_id : null;
|
return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectQuery(projectId?: string | null): string {
|
function projectQuery(projectId?: string | null): string {
|
||||||
@@ -239,20 +258,36 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getData<T>(path: string): Promise<T> {
|
async function getData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path);
|
const res = await request<any>(path);
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function postData<T>(path: string, body: unknown): Promise<T> {
|
async function postData<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
|
}
|
||||||
|
async function putData<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
async function deleteData<T>(path: string): Promise<T> {
|
async function deleteData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
const res = await request<any>(path, { method: "DELETE" });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
||||||
@@ -323,6 +358,12 @@ interface RawSupplier {
|
|||||||
interface RawProject {
|
interface RawProject {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
location?: string | null;
|
||||||
|
project_manager?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
progress?: number | null;
|
||||||
}
|
}
|
||||||
interface RawSpec {
|
interface RawSpec {
|
||||||
attribute_key: string;
|
attribute_key: string;
|
||||||
@@ -458,6 +499,39 @@ 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: 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 دانلود میآیند)
|
// فایل پیوست — پاسخ بکاند (metadata؛ بایتها از endpoint دانلود میآیند)
|
||||||
interface RawAttachment {
|
interface RawAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -927,13 +1001,28 @@ export const api = {
|
|||||||
|
|
||||||
if (becomingAccepted) {
|
if (becomingAccepted) {
|
||||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||||
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
// پروژهی واحدِ انتخابشده یا تخصیصهای چندپروژهای برای هر قلم
|
||||||
const targetProjByLine = new Map<string, string>();
|
const targetProjByLine = new Map<string, string>();
|
||||||
|
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
|
||||||
for (const li of patch.line_items || []) {
|
for (const li of patch.line_items || []) {
|
||||||
const tp = li.target_project;
|
if (li.allocations && li.allocations.length > 0) {
|
||||||
if (tp && tp.name) {
|
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
|
||||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
for (const a of li.allocations) {
|
||||||
if (pid) targetProjByLine.set(li.id, pid);
|
const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
|
||||||
|
if (pid && a.quantity > 0) {
|
||||||
|
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedAllocs.length) {
|
||||||
|
allocationsByLine.set(li.id, resolvedAllocs);
|
||||||
|
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tp = li.target_project;
|
||||||
|
if (tp && tp.name) {
|
||||||
|
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||||
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,11 +1042,13 @@ export const api = {
|
|||||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||||
const chosen = !li.rejected && idx >= 0;
|
const chosen = !li.rejected && idx >= 0;
|
||||||
const chosenProjId = targetProjByLine.get(li.id);
|
const chosenProjId = targetProjByLine.get(li.id);
|
||||||
|
const lineAllocs = allocationsByLine.get(li.id);
|
||||||
return {
|
return {
|
||||||
line_item_id: li.id,
|
line_item_id: li.id,
|
||||||
status: chosen ? "chosen" : "rejected",
|
status: chosen ? "chosen" : "rejected",
|
||||||
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
||||||
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
||||||
|
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
|
||||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -1193,16 +1284,51 @@ 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[]> => {
|
||||||
|
// The backend returns { data: [...clean supplier objects] } from an
|
||||||
|
// atomic JOIN; getData<T> unwraps the envelope. No fallback data —
|
||||||
|
// an empty array means the material genuinely has no suppliers.
|
||||||
|
const raw = await getData<RawMaterialSupplier[]>(
|
||||||
|
`/materials/${materialId}/suppliers`,
|
||||||
|
);
|
||||||
|
return raw.map(adaptMaterialSupplier);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: {
|
projects: {
|
||||||
list: async (): Promise<Project[]> => {
|
list: async (): Promise<Project[]> => {
|
||||||
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
|
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
|
||||||
return raw.map((p) => ({ id: p.id, name: p.name }));
|
return raw.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
location: p.location || "",
|
||||||
|
project_manager: p.project_manager || "",
|
||||||
|
description: p.description || "",
|
||||||
|
start_date: p.start_date,
|
||||||
|
status: p.status || "ACTIVE",
|
||||||
|
progress: p.progress != null ? Number(p.progress) : 0,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
create: async (p: Project): Promise<Project> => {
|
create: async (p: Project): Promise<Project> => {
|
||||||
const raw = await postData<RawProject>("/projects", { name: p.name });
|
const raw = await postData<RawProject>("/projects", {
|
||||||
return { id: raw.id, name: raw.name };
|
name: p.name,
|
||||||
|
location: p.location || "",
|
||||||
|
project_manager: p.project_manager || "",
|
||||||
|
description: p.description || "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
location: raw.location || "",
|
||||||
|
project_manager: raw.project_manager || "",
|
||||||
|
description: raw.description || "",
|
||||||
|
status: raw.status || "ACTIVE",
|
||||||
|
progress: raw.progress != null ? Number(raw.progress) : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await deleteData(`/projects/${id}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1592,11 +1718,16 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
|
|||||||
export interface UserRow {
|
export interface UserRow {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string | null;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
|
// لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', ...}
|
||||||
|
panels?: Record<string, string>;
|
||||||
|
// عضویت M:N پروژهای — محدوده داده سطح limited
|
||||||
|
member_project_ids?: string[];
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
// 1:N — every project this PM manages (oldest first).
|
// 1:N — every project this PM manages (oldest first).
|
||||||
@@ -1620,10 +1751,11 @@ export const apiV26 = {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
@@ -1637,6 +1769,7 @@ export const apiV26 = {
|
|||||||
expires_in: res.data.expires_in,
|
expires_in: res.data.expires_in,
|
||||||
user: {
|
user: {
|
||||||
...u,
|
...u,
|
||||||
|
username: u.username || u.casdoor_id,
|
||||||
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1656,14 +1789,15 @@ export const apiV26 = {
|
|||||||
users: {
|
users: {
|
||||||
me: async (): Promise<import("../store/auth").AuthUser> => {
|
me: async (): Promise<import("../store/auth").AuthUser> => {
|
||||||
const data = await getData<{
|
const data = await getData<{
|
||||||
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
|
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
|
||||||
project_id: string | null; project_name: string | null;
|
project_id: string | null; project_name: string | null;
|
||||||
projects?: { id: string; name: string }[];
|
projects?: { id: string; name: string }[];
|
||||||
}>("/users/me");
|
}>("/users/me");
|
||||||
return {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
casdoor_id: data.casdoor_id,
|
casdoor_id: data.casdoor_id,
|
||||||
|
username: data.username || data.casdoor_id,
|
||||||
first_name: data.first_name,
|
first_name: data.first_name,
|
||||||
last_name: data.last_name,
|
last_name: data.last_name,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
@@ -1687,8 +1821,13 @@ export const apiV26 = {
|
|||||||
first_name: string; last_name?: string; phone?: string | null;
|
first_name: string; last_name?: string; phone?: string | null;
|
||||||
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
||||||
}): Promise<UserRow> => postData<UserRow>("/users", body),
|
}): Promise<UserRow> => postData<UserRow>("/users", body),
|
||||||
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
|
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
|
||||||
patchData<UserRow>(`/users/${id}`, { role }),
|
patchData<UserRow>(`/users/${id}`, { role }),
|
||||||
|
// لایه پنلها — تخصیص پنل + سطح دسترسی و عضویت پروژهای (فقط ادمین)
|
||||||
|
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
|
||||||
|
putData<UserRow>(`/users/${id}/panels`, { panels }),
|
||||||
|
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
|
||||||
|
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
||||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||||
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||||
@@ -1736,16 +1875,23 @@ export const apiV26 = {
|
|||||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||||
return data.map(rawInventory);
|
return data.map(rawInventory);
|
||||||
},
|
},
|
||||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||||
},
|
},
|
||||||
|
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||||
|
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||||
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
|
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||||
|
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||||
|
},
|
||||||
createTransaction: async (body: {
|
createTransaction: async (body: {
|
||||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||||
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
||||||
note?: string | null; transaction_date?: string | null;
|
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
|
||||||
}): Promise<InventoryTransaction[]> => {
|
}): Promise<InventoryTransaction[]> => {
|
||||||
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
||||||
},
|
},
|
||||||
@@ -1756,8 +1902,12 @@ export const apiV26 = {
|
|||||||
|
|
||||||
pm: {
|
pm: {
|
||||||
// پروژه فعال بهصورت صریح ارسال میشود؛ بکاند تعلق آن به PM را بررسی میکند
|
// پروژه فعال بهصورت صریح ارسال میشود؛ بکاند تعلق آن به PM را بررسی میکند
|
||||||
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
|
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => {
|
||||||
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
|
const qs = new URLSearchParams(
|
||||||
|
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
|
||||||
|
).toString();
|
||||||
|
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
confirmReceipt: async (body: {
|
confirmReceipt: async (body: {
|
||||||
item_decision_id: string; received_qty: number;
|
item_decision_id: string; received_qty: number;
|
||||||
allocations: { warehouse_id: string; quantity: number }[];
|
allocations: { warehouse_id: string; quantity: number }[];
|
||||||
@@ -1768,6 +1918,27 @@ export const apiV26 = {
|
|||||||
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
|
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------- داشبورد MVP (v2.8) — تجمیعهای سمت سرور، فقطخواندنی ----------
|
||||||
|
// نقش/پروژه سمت بکاند از JWT (یا هدرهای dev) حل میشود؛ PM به پروژههای
|
||||||
|
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را میبیند.
|
||||||
|
dashboard: {
|
||||||
|
filters: async (): Promise<DashboardFilterOptions> =>
|
||||||
|
getData<DashboardFilterOptions>("/dashboard/filters"),
|
||||||
|
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
|
||||||
|
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
||||||
|
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
|
||||||
|
getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
||||||
|
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
||||||
|
getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"),
|
||||||
|
// KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats)
|
||||||
|
quoteStats: async (): Promise<QuoteStats> =>
|
||||||
|
getData<QuoteStats>("/quotes/stats"),
|
||||||
|
},
|
||||||
|
|
||||||
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
||||||
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته میشوند.
|
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته میشوند.
|
||||||
catalog: {
|
catalog: {
|
||||||
@@ -1796,4 +1967,41 @@ export const apiV26 = {
|
|||||||
attributes: [],
|
attributes: [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
projectTransfers: {
|
||||||
|
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (projectId) q.set("project_id", projectId);
|
||||||
|
if (direction) q.set("direction", direction);
|
||||||
|
if (status) q.set("status", status);
|
||||||
|
const qs = q.toString() ? `?${q.toString()}` : "";
|
||||||
|
try {
|
||||||
|
const res = await getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
|
||||||
|
return Array.isArray(res) ? res : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch project transfers:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||||
|
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
|
||||||
|
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
|
||||||
|
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
|
||||||
|
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||||
|
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
|
||||||
|
try {
|
||||||
|
const res = await getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`);
|
||||||
|
return Array.isArray(res) ? res : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch on-the-way transfers:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export interface Material {
|
|||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
project_manager?: string;
|
||||||
|
description?: string;
|
||||||
|
start_date?: string | null;
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحلهی پرداخت
|
// مرحلهی پرداخت
|
||||||
@@ -73,6 +79,7 @@ export interface LineItem {
|
|||||||
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||||
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||||
target_project_id?: string | null;
|
target_project_id?: string | null;
|
||||||
|
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژهای در پذیرش
|
||||||
}
|
}
|
||||||
|
|
||||||
// پیشنهاد خرید (اصلیترین entity)
|
// پیشنهاد خرید (اصلیترین entity)
|
||||||
@@ -163,6 +170,7 @@ export interface InventoryTransaction {
|
|||||||
physical_qty?: number | null;
|
physical_qty?: number | null;
|
||||||
transfer_pair_id?: string | null;
|
transfer_pair_id?: string | null;
|
||||||
performed_by?: string | null;
|
performed_by?: string | null;
|
||||||
|
delivered_to?: string | null;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
material?: { id: string; name: string } | null;
|
material?: { id: string; name: string } | null;
|
||||||
@@ -187,6 +195,12 @@ export interface PendingArrival {
|
|||||||
state: "on_the_way" | "partially_received" | "arrived";
|
state: "on_the_way" | "partially_received" | "arrived";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.1 — برند/مشخصههای فrozen قلم (اضافهشده به /pm/on-the-way برای داشبورد)
|
||||||
|
export interface InTransitRow extends PendingArrival {
|
||||||
|
brand: { id: string; name: string } | null;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfirmedReceipt {
|
export interface ConfirmedReceipt {
|
||||||
receipt_id: string;
|
receipt_id: string;
|
||||||
quote_id: string;
|
quote_id: string;
|
||||||
@@ -199,3 +213,200 @@ export interface ConfirmedReceipt {
|
|||||||
quality_note: string | null;
|
quality_note: string | null;
|
||||||
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// v2.8 — داشبورد MVP (انبار + بازرگانی)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// — فیلتر سراسری «چه چیزی» —
|
||||||
|
export interface FilterSpecOption {
|
||||||
|
hash: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterMaterialOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
brands: { id: string; name: string }[];
|
||||||
|
specs: FilterSpecOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardFilterOptions {
|
||||||
|
materials: FilterMaterialOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||||
|
export type QtyByUnit = Record<string, number>;
|
||||||
|
|
||||||
|
export interface DrilldownWarehouse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrilldownProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
warehouses: DrilldownWarehouse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrilldownMaterial {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
projects: DrilldownProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrilldownData {
|
||||||
|
units: Record<string, string>;
|
||||||
|
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||||
|
materials: DrilldownMaterial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — جدول موجودی راکد —
|
||||||
|
export interface DeadStockItem {
|
||||||
|
inventory_id: string;
|
||||||
|
material: string;
|
||||||
|
brand: string | null;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
warehouse: string;
|
||||||
|
project: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: { code: string; name_fa: string | null };
|
||||||
|
last_activity: string | null;
|
||||||
|
idle_days: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// — سری زمانی جریان روزانه —
|
||||||
|
// دستههای نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات
|
||||||
|
// (انتقال/شمارش/یافتشده که در چهار سبد نمایشی جا نمیگیرند)
|
||||||
|
export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other";
|
||||||
|
|
||||||
|
// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایهی شکست چندبعدی
|
||||||
|
export interface FlowBreakdownItem {
|
||||||
|
category: FlowCategory;
|
||||||
|
transaction_type: string;
|
||||||
|
specs: Record<string, string> | null;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // علامتدار مطابق دفتر (مثبت/منفی)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDay {
|
||||||
|
date: string; // ISO (YYYY-MM-DD)
|
||||||
|
inbound: number;
|
||||||
|
outbound: number;
|
||||||
|
waste: number;
|
||||||
|
returns: number;
|
||||||
|
balance: number;
|
||||||
|
breakdown?: FlowBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowData {
|
||||||
|
opening_balance: number;
|
||||||
|
// واحد کاتالوگی متریال انتخابشده (فقط وقتی یک متریال فیلتر شده باشد)
|
||||||
|
unit: { code: string; name_fa: string | null } | null;
|
||||||
|
days: FlowDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — کنترل روزانه —
|
||||||
|
export interface DailyControlData {
|
||||||
|
date: string;
|
||||||
|
outbound: { tickets: number; qty: number };
|
||||||
|
returns: { tickets: number; qty: number };
|
||||||
|
waste: { tickets: number; qty: number };
|
||||||
|
purchases: { tickets: number; qty: number };
|
||||||
|
performers: { name: string; tickets: number; qty: number }[];
|
||||||
|
items: {
|
||||||
|
id: string;
|
||||||
|
transaction_type: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_code: string;
|
||||||
|
performed_by: string | null;
|
||||||
|
note: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
material: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
transaction_date: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
|
||||||
|
export interface QuoteStats {
|
||||||
|
total: number;
|
||||||
|
offered: number;
|
||||||
|
accepted: number;
|
||||||
|
arrived: number;
|
||||||
|
rejected: number;
|
||||||
|
expired: number;
|
||||||
|
average_lead_time_days: number | null;
|
||||||
|
funnel: {
|
||||||
|
offered_total: number;
|
||||||
|
accepted_total: number;
|
||||||
|
arrived_total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// — بار تدارکات روی پروژهها (Project Density) —
|
||||||
|
export interface WorkloadProject {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
offered_quotes: number;
|
||||||
|
chosen_items: number;
|
||||||
|
open_orders: number;
|
||||||
|
ordered_qty: number;
|
||||||
|
received_qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransfer {
|
||||||
|
id: string;
|
||||||
|
from_project: { id: string; name: string };
|
||||||
|
from_warehouse: { id: string; name: string };
|
||||||
|
to_project: { id: string; name: string };
|
||||||
|
to_warehouse?: { id: string; name: string } | null;
|
||||||
|
material: { id: string; name: string };
|
||||||
|
brand?: { id: string; name: string } | null;
|
||||||
|
unit: { id: number; code: string; name_fa?: string | null };
|
||||||
|
quantity: number;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
specs_hash: string;
|
||||||
|
note?: string | null;
|
||||||
|
status: "in_transit" | "received" | "cancelled";
|
||||||
|
transfer_date: string;
|
||||||
|
transferred_by?: string | null;
|
||||||
|
received_at?: string | null;
|
||||||
|
received_by?: string | null;
|
||||||
|
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferCreateIn {
|
||||||
|
from_warehouse_id: string;
|
||||||
|
to_project_id: string;
|
||||||
|
material_id: string;
|
||||||
|
brand_id?: string | null;
|
||||||
|
unit_id: number;
|
||||||
|
quantity: number;
|
||||||
|
specs?: Record<string, string>;
|
||||||
|
note?: string | null;
|
||||||
|
transfer_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferUpdateIn {
|
||||||
|
to_project_id?: string;
|
||||||
|
quantity?: number;
|
||||||
|
note?: string | null;
|
||||||
|
transfer_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferReceiveIn {
|
||||||
|
allocations: { warehouse_id: string; quantity: number }[];
|
||||||
|
received_at?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
pm: "مدیر پروژه",
|
pm: "مدیر پروژه",
|
||||||
procurement_manager: "مدیر تدارکات",
|
procurement_manager: "مدیر تدارکات",
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
||||||
@@ -80,6 +81,26 @@ export function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
|
||||||
|
const toggleSupervisor = async (u: UserRow) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
const target = u.role === "supervisor" ? null : "supervisor";
|
||||||
|
try {
|
||||||
|
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
|
||||||
|
toast.success(
|
||||||
|
target === "supervisor"
|
||||||
|
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
|
||||||
|
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -129,12 +150,13 @@ export function AdminPanel() {
|
|||||||
<th className="py-2 pe-3">نام کامل</th>
|
<th className="py-2 pe-3">نام کامل</th>
|
||||||
<th className="py-2 pe-3">شناسه Casdoor</th>
|
<th className="py-2 pe-3">شناسه Casdoor</th>
|
||||||
<th className="py-2 pe-3">نقش فعلی</th>
|
<th className="py-2 pe-3">نقش فعلی</th>
|
||||||
<th className="py-2">مدیریت مدیر تدارکات</th>
|
<th className="py-2">مدیریت نقشها</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const isPMgr = u.role === "procurement_manager";
|
const isPMgr = u.role === "procurement_manager";
|
||||||
|
const isSup = u.role === "supervisor";
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
<tr key={u.id} className="border-b border-line/50">
|
||||||
<td className="py-2 pe-3 font-semibold">
|
<td className="py-2 pe-3 font-semibold">
|
||||||
@@ -147,17 +169,30 @@ export function AdminPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
disabled={busyId === u.id}
|
<button
|
||||||
onClick={() => void toggleProcurementManager(u)}
|
disabled={busyId === u.id}
|
||||||
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
onClick={() => void toggleProcurementManager(u)}
|
||||||
isPMgr
|
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||||
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
|
isPMgr
|
||||||
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
|
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
|
||||||
} disabled:opacity-50`}
|
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
|
||||||
>
|
} disabled:opacity-50`}
|
||||||
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
>
|
||||||
</button>
|
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onClick={() => void toggleSupervisor(u)}
|
||||||
|
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||||
|
isSup
|
||||||
|
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
|
||||||
|
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ export function LoginPage() {
|
|||||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const hasProjects =
|
||||||
|
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||||
|
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||||
|
if (user.role === "supervisor" && !hasProjects) {
|
||||||
|
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||||
@@ -88,8 +95,23 @@ export function LoginPage() {
|
|||||||
if (returnUrl) {
|
if (returnUrl) {
|
||||||
navigate(decodeURIComponent(returnUrl));
|
navigate(decodeURIComponent(returnUrl));
|
||||||
} else {
|
} else {
|
||||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
const panels = user.panels ?? {};
|
||||||
|
if (panels.procurement === "full") {
|
||||||
|
navigate("/");
|
||||||
|
} else if (panels.procurement === "limited") {
|
||||||
|
navigate("/requester");
|
||||||
|
} else if (panels.inventory) {
|
||||||
|
navigate("/inventory");
|
||||||
|
} else if (panels.pm) {
|
||||||
|
navigate("/panel/pm");
|
||||||
|
} else if (user.role === "supervisor") {
|
||||||
|
navigate("/panel/pm");
|
||||||
|
} else if (user.role === "pm") {
|
||||||
|
navigate("/inventory");
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||||
@@ -280,10 +302,21 @@ export function LoginPage() {
|
|||||||
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
|
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
|
||||||
<p className="mt-1 text-[12.5px] leading-6 text-muted">
|
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
|
||||||
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
|
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
|
||||||
|
مدیریت پروژه
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
|
||||||
|
تدارکات
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
|
||||||
|
انبارداری
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,134 @@ import {
|
|||||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||||
import { FileList } from "../../components/FileList";
|
import { FileList } from "../../components/FileList";
|
||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||||
|
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
|
||||||
|
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||||||
|
const TEENS = [
|
||||||
|
"ده",
|
||||||
|
"یازده",
|
||||||
|
"دوازده",
|
||||||
|
"سیزده",
|
||||||
|
"چهارده",
|
||||||
|
"پانزده",
|
||||||
|
"شانزده",
|
||||||
|
"هفده",
|
||||||
|
"هجده",
|
||||||
|
"نوزده",
|
||||||
|
];
|
||||||
|
const TENS = [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"بیست",
|
||||||
|
"سی",
|
||||||
|
"چهل",
|
||||||
|
"پنجاه",
|
||||||
|
"شصت",
|
||||||
|
"هفتاد",
|
||||||
|
"هشتاد",
|
||||||
|
"نود",
|
||||||
|
];
|
||||||
|
const HUNDREDS = [
|
||||||
|
"",
|
||||||
|
"صد",
|
||||||
|
"دویست",
|
||||||
|
"سیصد",
|
||||||
|
"چهارصد",
|
||||||
|
"پانصد",
|
||||||
|
"ششصد",
|
||||||
|
"هفتصد",
|
||||||
|
"هشتصد",
|
||||||
|
"نهصد",
|
||||||
|
];
|
||||||
|
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
|
||||||
|
const DECIMAL_SCALES: Record<number, string> = {
|
||||||
|
1: "دهم",
|
||||||
|
2: "صدم",
|
||||||
|
3: "هزارم",
|
||||||
|
4: "دههزارم",
|
||||||
|
};
|
||||||
|
|
||||||
|
function convertThreeDigits(n: number): string {
|
||||||
|
if (n === 0) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
const h = Math.floor(n / 100);
|
||||||
|
const rem = n % 100;
|
||||||
|
if (h > 0) parts.push(HUNDREDS[h]);
|
||||||
|
if (rem >= 10 && rem <= 19) {
|
||||||
|
parts.push(TEENS[rem - 10]);
|
||||||
|
} else {
|
||||||
|
const t = Math.floor(rem / 10);
|
||||||
|
const u = rem % 10;
|
||||||
|
if (t > 0) parts.push(TENS[t]);
|
||||||
|
if (u > 0) parts.push(ONES[u]);
|
||||||
|
}
|
||||||
|
return parts.join(" و ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEnglishDigits(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberToPersianWords(val: number | string | null | undefined): string {
|
||||||
|
if (val == null || val === "") return "";
|
||||||
|
const numStr = toEnglishDigits(String(val).trim());
|
||||||
|
const num = parseFloat(numStr);
|
||||||
|
if (isNaN(num)) return "";
|
||||||
|
if (num === 0) return "صفر";
|
||||||
|
|
||||||
|
const isNeg = num < 0;
|
||||||
|
const parts = Math.abs(num).toString().split(".");
|
||||||
|
const intStr = parts[0];
|
||||||
|
const decStr = parts[1];
|
||||||
|
|
||||||
|
let intNum = parseInt(intStr, 10);
|
||||||
|
let intResult = "";
|
||||||
|
|
||||||
|
if (intNum === 0) {
|
||||||
|
intResult = "صفر";
|
||||||
|
} else {
|
||||||
|
const chunks: number[] = [];
|
||||||
|
while (intNum > 0) {
|
||||||
|
chunks.push(intNum % 1000);
|
||||||
|
intNum = Math.floor(intNum / 1000);
|
||||||
|
}
|
||||||
|
const chunkWords: string[] = [];
|
||||||
|
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||||
|
const c = chunks[i];
|
||||||
|
if (c > 0) {
|
||||||
|
const w = convertThreeDigits(c);
|
||||||
|
const s = SCALES[i];
|
||||||
|
chunkWords.push(s ? `${w} ${s}` : w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intResult = chunkWords.join(" و ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decStr && parseInt(decStr, 10) > 0) {
|
||||||
|
const cleanDecStr = decStr.slice(0, 4);
|
||||||
|
const decNum = parseInt(cleanDecStr, 10);
|
||||||
|
const decWord = numberToPersianWords(decNum);
|
||||||
|
const scale = DECIMAL_SCALES[cleanDecStr.length];
|
||||||
|
if (scale) {
|
||||||
|
if (intNum === 0) {
|
||||||
|
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
|
||||||
|
}
|
||||||
|
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
|
||||||
|
}
|
||||||
|
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNeg ? `منفی ${intResult}` : intResult;
|
||||||
|
}
|
||||||
|
|
||||||
interface CardDraft {
|
interface CardDraft {
|
||||||
domId: string;
|
domId: string;
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
label: string | null; // null = پیشفرض «گزینه N»
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcceptModalProps {
|
export interface AcceptModalProps {
|
||||||
@@ -51,6 +173,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||||
|
const [itemAllocations, setItemAllocations] = useState<
|
||||||
|
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
|
||||||
|
>({});
|
||||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
@@ -67,17 +192,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setRemovedItems(rem);
|
setRemovedItems(rem);
|
||||||
setAssignments({});
|
setAssignments({});
|
||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
|
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||||
projSel[li.id] = li.target_project?.name
|
const defaultProj = li.target_project?.name
|
||||||
? li.target_project.name
|
? li.target_project.name
|
||||||
: names.length
|
: names.length
|
||||||
? names[0]
|
? names[0]
|
||||||
: q.projects && q.projects.length
|
: q.projects && q.projects.length
|
||||||
? q.projects[0]
|
? q.projects[0]
|
||||||
: q.project || "";
|
: q.project || "";
|
||||||
|
projSel[li.id] = defaultProj;
|
||||||
|
if (li.allocations && li.allocations.length) {
|
||||||
|
allocs[li.id] = li.allocations.map((a, idx) => ({
|
||||||
|
rowId: `alloc-${li.id}-${idx}`,
|
||||||
|
project_name: a.project_name,
|
||||||
|
quantity: a.quantity,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
allocs[li.id] = [
|
||||||
|
{
|
||||||
|
rowId: `alloc-${li.id}-0`,
|
||||||
|
project_name: defaultProj,
|
||||||
|
quantity: li.quantity ?? 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setItemProjectsSel(projSel);
|
setItemProjectsSel(projSel);
|
||||||
|
setItemAllocations(allocs);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
@@ -137,6 +280,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
setAssignments((a) => ({ ...a, [liId]: domId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addAllocationRow = (liId: string, totalQty: number) => {
|
||||||
|
setItemAllocations((prev) => {
|
||||||
|
const currentList = prev[liId] || [];
|
||||||
|
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||||
|
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
|
||||||
|
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
|
||||||
|
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
|
||||||
|
const newRow = {
|
||||||
|
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
project_name: firstAvailable,
|
||||||
|
quantity: remaining > 0 ? remaining : "",
|
||||||
|
};
|
||||||
|
return { ...prev, [liId]: [...currentList, newRow] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAllocationRow = (liId: string, rowId: string) => {
|
||||||
|
setItemAllocations((prev) => {
|
||||||
|
const currentList = prev[liId] || [];
|
||||||
|
if (currentList.length <= 1) return prev;
|
||||||
|
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAllocationRow = (
|
||||||
|
liId: string,
|
||||||
|
rowId: string,
|
||||||
|
patch: Partial<{ project_name: string; quantity: number | string }>,
|
||||||
|
) => {
|
||||||
|
setItemAllocations((prev) => {
|
||||||
|
const currentList = prev[liId] || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
@@ -162,6 +343,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (removedItems.has(li.id)) li.rejected = true;
|
if (removedItems.has(li.id)) li.rejected = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const active = activeItems(next);
|
const active = activeItems(next);
|
||||||
if (!active.length) {
|
if (!active.length) {
|
||||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||||
@@ -206,13 +388,60 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
for (const li of active) {
|
||||||
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
const allocs = itemAllocations[li.id] || [];
|
||||||
|
const m = materialById(materials, li.material_id);
|
||||||
|
const title = formatMaterialTitle(m);
|
||||||
|
const targetQty = Number(li.quantity ?? 0);
|
||||||
|
|
||||||
|
if (!allocs.length) {
|
||||||
|
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of allocs) {
|
||||||
|
if (!a.project_name.trim()) {
|
||||||
|
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیفهای تخصیص را مشخص کنید.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qVal = parseFloat(String(a.quantity));
|
||||||
|
if (isNaN(qVal) || qVal <= 0) {
|
||||||
|
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگتر از صفر باشد.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pNames = allocs.map((a) => a.project_name.trim());
|
||||||
|
if (new Set(pNames).size !== pNames.length) {
|
||||||
|
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||||
|
const diff = Math.abs(sumQty - targetQty);
|
||||||
|
if (diff > 0.0001) {
|
||||||
|
toast.error(
|
||||||
|
`مجموع مقادیر تخصیصیافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const allocs = itemAllocations[li.id];
|
||||||
if (name === undefined) return;
|
if (allocs && allocs.length) {
|
||||||
li.target_project = name ? { id: "", name } : null;
|
li.allocations = allocs.map((a) => ({
|
||||||
|
project_name: a.project_name.trim(),
|
||||||
|
quantity: parseFloat(String(a.quantity)) || 0,
|
||||||
|
}));
|
||||||
|
li.target_project = { id: "", name: allocs[0].project_name.trim() };
|
||||||
|
} else {
|
||||||
|
const name = itemProjectsSel[li.id];
|
||||||
|
if (name !== undefined) {
|
||||||
|
li.target_project = name ? { id: "", name } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -232,7 +461,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||||
<div className="flex flex-col max-h-[85vh] w-full">
|
<div className="flex flex-col max-h-[85vh] w-full">
|
||||||
|
|
||||||
{/* هدر ثابت و همتراز */}
|
{/* هدر ثابت */}
|
||||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||||
<DialogHeader className="p-0 text-right">
|
<DialogHeader className="p-0 text-right">
|
||||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||||
@@ -343,53 +572,196 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
{/* بخش ۳: تخصیص اقلام به پروژهها */}
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||||
|
</div>
|
||||||
<div className="mb-4 text-[12px] text-muted">
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده میشود. در صورت خرید یک کالا برای چند پروژه، میتوانید آن را به چند سطر تفکیک کنید.
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{allItems
|
{projectOptions.length === 0 ? (
|
||||||
.filter((li) => !removedItems.has(li.id))
|
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
|
||||||
.map((li) => {
|
⚠️ هیچ پروژهای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژهها، حداقل یک پروژه ایجاد کنید.
|
||||||
const m = materialById(materials, li.material_id);
|
</div>
|
||||||
return (
|
) : (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
{allItems
|
||||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
.filter((li) => !removedItems.has(li.id))
|
||||||
{formatMaterialTitle(m)}
|
.map((li) => {
|
||||||
</span>
|
const m = materialById(materials, li.material_id);
|
||||||
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
const targetQty = Number(li.quantity ?? 0);
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
const allocs = itemAllocations[li.id] || [];
|
||||||
</span>
|
const allocSum = allocs.reduce(
|
||||||
|
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
|
||||||
|
const isComplete = Math.abs(diff) < 0.0001;
|
||||||
|
const isOver = diff < -0.0001;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={li.id}
|
||||||
|
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
|
||||||
|
>
|
||||||
|
{/* هدر کارت قلم */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span className="text-[13.5px] font-bold text-slate-800">
|
||||||
|
{formatMaterialTitle(m)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
|
||||||
|
کل: {fmtNum(targetQty)} {li.unit}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isComplete ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
|
||||||
|
</span>
|
||||||
|
) : isOver ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
باقیمانده: {fmtNum(diff)} {li.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||||||
|
<div className="flex flex-col gap-2.5 mb-3">
|
||||||
|
{allocs.map((alloc) => {
|
||||||
|
const otherUsedProjects = new Set(
|
||||||
|
allocs
|
||||||
|
.filter((a) => a.rowId !== alloc.rowId)
|
||||||
|
.map((a) => a.project_name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const words = numberToPersianWords(alloc.quantity);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.rowId}
|
||||||
|
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||||||
|
>
|
||||||
|
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<select
|
||||||
|
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
|
||||||
|
value={alloc.project_name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
|
project_name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">انتخاب پروژه...</option>
|
||||||
|
{projectOptions.map((p) => {
|
||||||
|
const isUsed = otherUsedProjects.has(p);
|
||||||
|
return (
|
||||||
|
<option key={p} value={p} disabled={isUsed}>
|
||||||
|
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف موبایل */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={allocs.length <= 1}
|
||||||
|
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||||
|
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
|
||||||
|
<div className="flex flex-col w-full sm:w-44 shrink-0">
|
||||||
|
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
placeholder="مقدار"
|
||||||
|
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ MozAppearance: "textfield" }}
|
||||||
|
dir="ltr"
|
||||||
|
value={alloc.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
|
quantity: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
|
||||||
|
title={`واحد: ${li.unit}`}
|
||||||
|
>
|
||||||
|
{li.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{words ? (
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
|
||||||
|
{words} {li.unit}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپداون و اینپوت */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={allocs.length <= 1}
|
||||||
|
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||||
|
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فوتر کارت قلم */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addAllocationRow(li.id, targetQty)}
|
||||||
|
disabled={
|
||||||
|
allocs.length >= projectOptions.length && projectOptions.length > 0
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{projectOptions.length ? (
|
);
|
||||||
<select
|
})}
|
||||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
</div>
|
||||||
value={itemProjectsSel[li.id] ?? ""}
|
)}
|
||||||
onChange={(e) =>
|
|
||||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
|
||||||
<option key={i} value="">
|
|
||||||
انتخاب پروژه...
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{projectOptions.map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{n}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||||
@@ -441,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر ثابت */}
|
{/* فوتر ثابت */}
|
||||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||||
ثبت معامله بستهشده
|
ثبت معامله بستهشده
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
|
|||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { AcceptModal } from "./AcceptModal";
|
import { AcceptModal } from "./AcceptModal";
|
||||||
import { EditModal } from "./EditModal";
|
import { EditModal } from "./EditModal";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
|
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
@@ -85,11 +89,20 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-20 md:pb-6">
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
<Header
|
<Header
|
||||||
title="دفتر پیشنهادهای خرید"
|
title="مدیریت تدارکات"
|
||||||
brand
|
brand
|
||||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
actions={
|
actions={
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
{/* داشبورد تحلیلی — دکمهی مستقل، همسبک با «پروژهها» و «ضرایب تبدیل» */}
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ChartColumnBig className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||||
|
<span>داشبورد</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||||
<Users className="h-3.5 w-3.5" />
|
<Users className="h-3.5 w-3.5" />
|
||||||
تأمینکنندهها
|
تأمینکنندهها
|
||||||
@@ -114,13 +127,15 @@ export function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||||
<Link
|
{canWrite && (
|
||||||
to="/wizard"
|
<Link
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
to="/wizard"
|
||||||
>
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
<span>افزودن پیشنهاد جدید</span>
|
<Plus className="h-4 w-4" />
|
||||||
</Link>
|
<span>افزودن پیشنهاد جدید</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -148,17 +163,20 @@ export function DashboardPage() {
|
|||||||
onToggleExpand={toggleExpand}
|
onToggleExpand={toggleExpand}
|
||||||
onAccept={setAcceptId}
|
onAccept={setAcceptId}
|
||||||
onEdit={setEditId}
|
onEdit={setEditId}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
||||||
<Link
|
{canWrite && (
|
||||||
to="/wizard"
|
<Link
|
||||||
aria-label="افزودن پیشنهاد جدید"
|
to="/wizard"
|
||||||
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
|
aria-label="افزودن پیشنهاد جدید"
|
||||||
>
|
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
|
||||||
<Plus className="h-7 w-7" />
|
>
|
||||||
</Link>
|
<Plus className="h-7 w-7" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||||
|
|||||||
@@ -438,7 +438,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
li.target_project = null;
|
li.target_project = null;
|
||||||
li.target_project_id = null;
|
li.target_project_id = null;
|
||||||
|
li.allocations = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ادغام سطرهای تفکیکشده حاصل از تخصیص چندپروژهای (الزام ۲)
|
||||||
|
const mergedMap = new Map<string, LineItem>();
|
||||||
|
for (const li of next.line_items) {
|
||||||
|
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
|
||||||
|
const existing = mergedMap.get(key);
|
||||||
|
if (existing) {
|
||||||
|
const q1 = Number(existing.quantity ?? 0);
|
||||||
|
const q2 = Number(li.quantity ?? 0);
|
||||||
|
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
|
||||||
|
if (existing.unit_price != null && existing.quantity != null) {
|
||||||
|
existing.total_price = existing.unit_price * existing.quantity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mergedMap.set(key, { ...li });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.line_items = Array.from(mergedMap.values());
|
||||||
} else if (next.status === "accepted") {
|
} else if (next.status === "accepted") {
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
|
|||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
onAccept: (id: string) => void;
|
onAccept: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
||||||
@@ -20,6 +21,7 @@ export function LedgerTable({
|
|||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onAccept,
|
onAccept,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
canWrite = true,
|
||||||
}: LedgerTableProps) {
|
}: LedgerTableProps) {
|
||||||
if (!quotes.length) {
|
if (!quotes.length) {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +42,7 @@ export function LedgerTable({
|
|||||||
onToggleExpand={() => onToggleExpand(q.id)}
|
onToggleExpand={() => onToggleExpand(q.id)}
|
||||||
onAccept={() => onAccept(q.id)}
|
onAccept={() => onAccept(q.id)}
|
||||||
onEdit={() => onEdit(q.id)}
|
onEdit={() => onEdit(q.id)}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// ============================================================
|
||||||
|
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
|
||||||
|
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابهجایی
|
||||||
|
// بین تبها حفظ میشود). تمام ویجتها مستقیم به API واقعی وصلاند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BarChart3, Warehouse } from "lucide-react";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
|
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
|
||||||
|
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
|
||||||
|
import { FlowChart } from "../../components/dashboard/FlowChart";
|
||||||
|
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
|
||||||
|
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
|
||||||
|
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
|
||||||
|
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
|
||||||
|
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
|
||||||
|
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type Tab = "warehouse" | "procurement";
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
|
||||||
|
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
|
||||||
|
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MvpDashboardPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>("warehouse");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
|
<Header
|
||||||
|
title="داشبورد تحلیلی"
|
||||||
|
sub="انبار و تدارکات — فیلتر جنس روی همهی ویجتها اعمال میشود"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
|
actions={
|
||||||
|
/* تبهای نما — سگمنتکنترل هماهنگ با دیزاینسیستم */
|
||||||
|
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
|
||||||
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
|
||||||
|
tab === key
|
||||||
|
? "bg-panel text-accent shadow-sm"
|
||||||
|
: "text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||||
|
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
|
||||||
|
<DashboardFilterBar />
|
||||||
|
|
||||||
|
{tab === "warehouse" ? (
|
||||||
|
/* ——— نمای A: انبار ——— */
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
|
||||||
|
<DrilldownMatrix />
|
||||||
|
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<FlowChart />
|
||||||
|
<DailyControlPanel />
|
||||||
|
</div>
|
||||||
|
{/* ردیف سوم: موجودی راکد */}
|
||||||
|
<DeadStockTable />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ——— نمای B: بازرگانی و لجستیک ——— */
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* KPI + قیف */}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<LeadTimeKpiCard />
|
||||||
|
<div className="xl:col-span-2">
|
||||||
|
<ProcurementFunnel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* تراکم پروژهها + سفارشهای در راه */}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<ProjectDensityChart />
|
||||||
|
<InTransitGrid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
|
|||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||||
@@ -35,7 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
|
||||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||||
const expired = isExpired(q);
|
const expired = isExpired(q);
|
||||||
const total = quoteTotal(q);
|
const total = quoteTotal(q);
|
||||||
@@ -46,7 +47,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
// ستون / دکمه پذیرش
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
if (q.status === "offered") {
|
if (q.status === "offered") {
|
||||||
acceptedCell = (
|
acceptedCell = canWrite ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -57,6 +58,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
<span>پذیرش</span>
|
<span>پذیرش</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
|
||||||
);
|
);
|
||||||
} else if (q.accepted_date) {
|
} else if (q.accepted_date) {
|
||||||
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
||||||
@@ -185,19 +188,21 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* دکمه ویرایش */}
|
{/* دکمه ویرایش */}
|
||||||
<button
|
{canWrite && (
|
||||||
type="button"
|
<button
|
||||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
type="button"
|
||||||
onClick={(e) => {
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onEdit();
|
e.stopPropagation();
|
||||||
}}
|
onEdit();
|
||||||
title="ویرایش پیشنهاد"
|
}}
|
||||||
aria-label="ویرایش"
|
title="ویرایش پیشنهاد"
|
||||||
>
|
aria-label="ویرایش"
|
||||||
<Pencil className="h-3 w-3 text-muted" />
|
>
|
||||||
<span className="hidden sm:inline">ویرایش</span>
|
<Pencil className="h-3 w-3 text-muted" />
|
||||||
</button>
|
<span className="hidden sm:inline">ویرایش</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* دکمه فلش اکسپند در هدر */}
|
{/* دکمه فلش اکسپند در هدر */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { FilePlus2, Info } from "lucide-react";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
import { useProjects } from "../../hooks";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژههای تخصیصیافته
|
||||||
|
export function RequesterHome() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const projects = useProjects();
|
||||||
|
|
||||||
|
// پروژههای کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژههای عمومی)
|
||||||
|
const myProjects = useMemo(() => {
|
||||||
|
const list: { id: string; name: string }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// ۱. پروژههای موجود در آرایه user.projects
|
||||||
|
for (const p of user?.projects ?? []) {
|
||||||
|
if (p.id && !seen.has(p.id)) {
|
||||||
|
seen.add(p.id);
|
||||||
|
list.push({ id: p.id, name: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. پروژهی اختصاصی فعال (project_id)
|
||||||
|
if (user?.project_id && !seen.has(user.project_id)) {
|
||||||
|
seen.add(user.project_id);
|
||||||
|
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیصیافته" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳. پروژههای عضویت member_project_ids تطبیقیافته با لیست پروژهها
|
||||||
|
const memberIdSet = new Set(user?.member_project_ids ?? []);
|
||||||
|
for (const p of projects) {
|
||||||
|
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
|
||||||
|
seen.add(p.id);
|
||||||
|
list.push({ id: p.id, name: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
|
||||||
|
if (list.length === 0 && user?.project_name) {
|
||||||
|
const match = projects.find((p) => p.name === user.project_name);
|
||||||
|
if (match && !seen.has(match.id)) {
|
||||||
|
list.push({ id: match.id, name: match.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [user, projects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header
|
||||||
|
title="افزودن استعلام"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
|
/>
|
||||||
|
<PageLayout className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
شما مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{myProjects.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
|
||||||
|
هنوز پروژهای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{myProjects.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to={`/wizard?project=${encodeURIComponent(p.name)}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<b className="text-[13.5px]">🏢 {p.name}</b>
|
||||||
|
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
|
||||||
|
<FilePlus2 className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/wizard"
|
||||||
|
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
|
||||||
|
>
|
||||||
|
<FilePlus2 className="h-3.5 w-3.5" />
|
||||||
|
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
|
|||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
import { apiV26 } from "../../lib/api";
|
import { apiV26 } from "../../lib/api";
|
||||||
import { refreshProcurementStore } from "../../hooks";
|
import { refreshProcurementStore } from "../../hooks";
|
||||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
|
||||||
import { faNum, fmtDate } from "../../lib/formatters";
|
import { faNum, fmtDate } from "../../lib/formatters";
|
||||||
|
|
||||||
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
||||||
@@ -23,36 +23,45 @@ export function ArrivalsPage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
const isPM = user?.role === "pm";
|
const isPM = user?.role === "pm";
|
||||||
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const canAccess = isAdmin || isPM || isSupervisor;
|
||||||
|
|
||||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||||
|
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
||||||
|
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
||||||
|
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
|
||||||
const [arr, whs] = await Promise.all([
|
const [arr, whs, incoming] = await Promise.all([
|
||||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM / سرپرست را بررسی میکند
|
||||||
apiV26.pm.onTheWay(activeProject),
|
apiV26.pm.onTheWay(activeProject),
|
||||||
apiV26.warehouses.list(activeProject),
|
apiV26.warehouses.list(activeProject),
|
||||||
|
apiV26.projectTransfers.onTheWay(activeProject),
|
||||||
]);
|
]);
|
||||||
setItems(arr);
|
setItems(Array.isArray(arr) ? arr : []);
|
||||||
setWarehouses(whs);
|
setWarehouses(Array.isArray(whs) ? whs : []);
|
||||||
|
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||||
|
setItems([]);
|
||||||
|
setIncomingTransfers([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [isPM, user?.project_id]);
|
}, [isPM, isSupervisor, user?.project_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin && !isPM) return;
|
if (!canAccess) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void load();
|
void load();
|
||||||
}, [isAdmin, isPM, load]);
|
}, [canAccess, load]);
|
||||||
|
|
||||||
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
@@ -212,12 +221,60 @@ export function ArrivalsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin && !isPM) {
|
// ---------- transfer helpers ----------
|
||||||
|
|
||||||
|
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
|
||||||
|
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
|
||||||
|
return transferDrafts[t.id];
|
||||||
|
}
|
||||||
|
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
|
||||||
|
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
|
||||||
|
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
|
||||||
|
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||||
|
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||||
|
const targetQty = parseFloat(String(transfer.quantity));
|
||||||
|
|
||||||
|
if (allocSum !== targetQty) {
|
||||||
|
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
|
||||||
|
toast.error("لطفاً برای تمامی ردیفهای دارای مقدار، انبار مقصد را انتخاب کنید.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransferSaving(transfer.id);
|
||||||
|
try {
|
||||||
|
const payloadAllocs = allocs
|
||||||
|
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||||
|
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||||
|
|
||||||
|
await apiV26.projectTransfers.receive(transfer.id, {
|
||||||
|
allocations: payloadAllocs,
|
||||||
|
}, activeProject);
|
||||||
|
|
||||||
|
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
|
||||||
|
} finally {
|
||||||
|
setTransferSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
|
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -233,19 +290,171 @@ export function ArrivalsPage() {
|
|||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
🚚 این بخش به مرحله «رسید» سفارشهای خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژهها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده میشوند؛ سفارشهای خرید نیز قابلیت رسید کامل یا جزیی دارند.
|
||||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
|
||||||
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
|
||||||
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
|
||||||
ماندهی سفارش بیشتر نشود.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
||||||
) : !orders.length ? (
|
) : !(orders || []).length && !(incomingTransfers || []).length ? (
|
||||||
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
||||||
) : (
|
) : (
|
||||||
orders.map(([quoteId, its]) => {
|
<>
|
||||||
|
{/* بخش انتقالهای بین کارگاهی در راه */}
|
||||||
|
{(incomingTransfers || []).length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقالهای بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
|
||||||
|
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژهها</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(incomingTransfers || []).map((t) => {
|
||||||
|
const draftAllocs = transferDraftOf(t);
|
||||||
|
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||||
|
const targetQty = parseFloat(String(t.quantity));
|
||||||
|
const isExactMatch = allocSum === targetQty;
|
||||||
|
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
|
||||||
|
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
|
||||||
|
const days = daysElapsed(t.transfer_date);
|
||||||
|
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
|
||||||
|
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||||
|
{/* سربرگ انتقال */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
|
||||||
|
<Badge variant="offered">در راه</Badge>
|
||||||
|
{days !== null && (
|
||||||
|
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||||
|
روز گذشته: {faNum(days)} روز
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
|
||||||
|
<span>ارسالکننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
|
||||||
|
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
|
||||||
|
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[13px] font-bold">{t.material?.name}</span>
|
||||||
|
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
|
||||||
|
<Badge variant="outline">
|
||||||
|
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
|
||||||
|
</Badge>
|
||||||
|
{t.specs && Object.keys(t.specs).length > 0 && (
|
||||||
|
<span className="text-[11.5px] text-muted">
|
||||||
|
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t.note && (
|
||||||
|
<div className="mt-1 text-[11.5px] text-muted">
|
||||||
|
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
|
||||||
|
<div className="text-[12px] font-semibold text-muted mb-2">
|
||||||
|
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{draftAllocs.map((a) => (
|
||||||
|
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
|
placeholder="تعداد"
|
||||||
|
value={a.qty}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
|
||||||
|
value={a.whId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— انتخاب انبار پروژه مقصد —</option>
|
||||||
|
{destWhOptions.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>{w.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{draftAllocs.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 px-2 text-muted hover:text-red transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
const next = draftAllocs.filter((x) => x.key !== a.key);
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ افزودن انبار دیگر برای تقسیم بار
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
|
||||||
|
<div>
|
||||||
|
{isExactMatch ? (
|
||||||
|
<span className="font-semibold text-emerald-600">
|
||||||
|
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-rose-600">
|
||||||
|
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasEmptyWh && (
|
||||||
|
<span className="ms-2 font-bold text-rose-600">
|
||||||
|
— لطفاً انبار مقصد را انتخاب کنید
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="green"
|
||||||
|
disabled={!isValid || transferSaving === t.id}
|
||||||
|
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
|
||||||
|
>
|
||||||
|
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* بخش سفارشهای خرید در راه */}
|
||||||
|
{orders.map(([quoteId, its]) => {
|
||||||
const first = its[0];
|
const first = its[0];
|
||||||
const open = expanded.has(quoteId);
|
const open = expanded.has(quoteId);
|
||||||
const hasSubmittable = orderHasSubmittable(quoteId);
|
const hasSubmittable = orderHasSubmittable(quoteId);
|
||||||
@@ -401,7 +610,8 @@ export function ArrivalsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,10 +41,12 @@ export function DailyControlPage() {
|
|||||||
|
|
||||||
const txByDay = useMemo(() => {
|
const txByDay = useMemo(() => {
|
||||||
const map = new Map<string, InventoryTransaction[]>();
|
const map = new Map<string, InventoryTransaction[]>();
|
||||||
|
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
|
||||||
const projWhIds = projFilter
|
const projWhIds = projFilter
|
||||||
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
|
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
|
||||||
: null;
|
: null;
|
||||||
data.transactions.forEach((t) => {
|
data.transactions.forEach((t) => {
|
||||||
|
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
|
||||||
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
|
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
|
||||||
const k = gregKey(new Date(t.transaction_date));
|
const k = gregKey(new Date(t.transaction_date));
|
||||||
const arr = map.get(k) || [];
|
const arr = map.get(k) || [];
|
||||||
@@ -53,7 +55,7 @@ export function DailyControlPage() {
|
|||||||
});
|
});
|
||||||
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
||||||
return map;
|
return map;
|
||||||
}, [data.transactions, data.warehouses, projFilter]);
|
}, [data.transactions, data.warehouses, projFilter, isAdmin]);
|
||||||
|
|
||||||
const cells = useMemo(() => {
|
const cells = useMemo(() => {
|
||||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||||
@@ -129,9 +131,10 @@ export function DailyControlPage() {
|
|||||||
const wGroups = useMemo(() => {
|
const wGroups = useMemo(() => {
|
||||||
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
||||||
withdrawals.forEach((t) => {
|
withdrawals.forEach((t) => {
|
||||||
const g = gmap.get(t.inventory_id);
|
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
|
||||||
|
const g = gmap.get(gKey);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||||
} else {
|
} else {
|
||||||
g.q += Math.abs(t.quantity_number || 0);
|
g.q += Math.abs(t.quantity_number || 0);
|
||||||
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
||||||
@@ -172,11 +175,15 @@ export function DailyControlPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-semibold text-slate-800">پروژه فعال</span>
|
<span className="font-semibold text-slate-800">
|
||||||
|
{user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="text-[11.5px] text-slate-400">
|
||||||
|
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11.5px] text-slate-400">
|
|
||||||
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -324,6 +331,12 @@ export function DailyControlPage() {
|
|||||||
whName(data.warehouses, g.tx.warehouse_id)
|
whName(data.warehouses, g.tx.warehouse_id)
|
||||||
].filter(Boolean).join(" · ")}
|
].filter(Boolean).join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
|
{g.tx.delivered_to && (
|
||||||
|
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
|
||||||
|
<span className="text-slate-500 font-normal">تحویل به:</span>
|
||||||
|
<span>{g.tx.delivered_to}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
||||||
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ export function InventoryPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
const isPM = user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
// پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
|
||||||
|
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||||
@@ -67,10 +70,10 @@ export function InventoryPage() {
|
|||||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||||
|
|
||||||
const visibleWarehouses = useMemo(() => {
|
const visibleWarehouses = useMemo(() => {
|
||||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||||
if (isAdmin) return warehouses;
|
if (isManager) return warehouses;
|
||||||
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
||||||
}, [warehouses, isAdmin, filters.project, user?.project_id]);
|
}, [warehouses, isManager, filters.project, user?.project_id]);
|
||||||
|
|
||||||
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
||||||
|
|
||||||
@@ -133,12 +136,12 @@ export function InventoryPage() {
|
|||||||
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen" />;
|
if (loading) return <div className="min-h-screen" />;
|
||||||
if (!user || (!isAdmin && !isPM)) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
|
<EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -150,10 +153,10 @@ export function InventoryPage() {
|
|||||||
title="سامانه مدیریت انبار"
|
title="سامانه مدیریت انبار"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
menu={<AppSwitcherMenu activeModule="warehouse" />}
|
||||||
actions={
|
actions={
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
<InventoryTopNav pathname={location.pathname} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -246,8 +249,8 @@ export function InventoryPage() {
|
|||||||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||||
{showMobileFilters && (
|
{showMobileFilters && (
|
||||||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||||
{/* پروژهها (مخصوص ادمین) */}
|
{/* پروژهها (مدیران و ادمین) */}
|
||||||
{isAdmin && lookup.projects.length > 0 && (
|
{isManager && lookup.projects.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -415,7 +418,7 @@ export function InventoryPage() {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور تغییر موجودی */}
|
{/* دکمه شناور تغییر موجودی */}
|
||||||
{isPM && (
|
{canModify && (
|
||||||
<Link
|
<Link
|
||||||
to="/inventory/change"
|
to="/inventory/change"
|
||||||
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
|
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاینسیستم دکمهها
|
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاینسیستم دکمهها
|
||||||
export function InventoryTopNav({
|
export function InventoryTopNav({
|
||||||
pathname,
|
pathname,
|
||||||
variant = "pm",
|
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
variant?: "pm" | "admin";
|
|
||||||
}) {
|
}) {
|
||||||
const items =
|
const user = useAuthStore((s) => s.user);
|
||||||
variant === "admin"
|
const isAdmin = user?.role === "admin";
|
||||||
? [
|
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
|
||||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
const isSupervisor = user?.role === "supervisor";
|
||||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
const canViewTabs = isManager || isSupervisor;
|
||||||
{ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") },
|
|
||||||
]
|
const items: { label: string; to: string; active: boolean }[] = [];
|
||||||
: [
|
// برای تغییر موجودی از همان دکمه شناور دایرهای آبی (+) استفاده میشود و دکمه مستطیلی در بالای صفحه حذف شد
|
||||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
if (canViewTabs) {
|
||||||
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
|
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
||||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
||||||
];
|
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
|
||||||
import { inventorySubheader } from "./panelMenu";
|
|
||||||
import { apiV26, type UserRow } from "../../lib/api";
|
|
||||||
import { refreshProcurementStore } from "../../hooks";
|
|
||||||
import { useInventoryData } from "./useInventoryData";
|
|
||||||
import { faNum } from "../../lib/formatters";
|
|
||||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
|
||||||
|
|
||||||
type Role = "pm" | "procurement_manager" | "admin" | null;
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
pm: "مدیر پروژه",
|
|
||||||
procurement_manager: "مدیر تدارکات",
|
|
||||||
admin: "ادمین",
|
|
||||||
};
|
|
||||||
|
|
||||||
// سازماندهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
|
|
||||||
export function OrganizationPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const data = useInventoryData();
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
|
||||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
|
||||||
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
|
||||||
// فقط ادمین میتواند نقشها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
|
|
||||||
// پروژهی مدیران پروژه را تعیین/جابهجا میکند.
|
|
||||||
const canChangeRoles = user?.role === "admin";
|
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const list = await apiV26.users.list();
|
|
||||||
setUsers(list);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
|
||||||
} finally {
|
|
||||||
setLoadingUsers(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (canManage) void loadUsers();
|
|
||||||
}, [canManage, loadUsers]);
|
|
||||||
|
|
||||||
const syncUsers = async () => {
|
|
||||||
setSyncing(true);
|
|
||||||
try {
|
|
||||||
const { users: synced, created } = await apiV26.users.sync();
|
|
||||||
setUsers(synced);
|
|
||||||
toast.success(
|
|
||||||
created > 0
|
|
||||||
? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
|
|
||||||
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
|
||||||
);
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!canManage) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header title="سازماندهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- actions: users ----------
|
|
||||||
|
|
||||||
const changeRole = async (u: UserRow, role: Role) => {
|
|
||||||
setBusyId(u.id);
|
|
||||||
try {
|
|
||||||
await apiV26.users.updateRole(u.id, role);
|
|
||||||
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
|
|
||||||
await loadUsers();
|
|
||||||
refreshProcurementStore();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// پروژههای متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تکپروژهای
|
|
||||||
const assignedProjectsOf = (u: UserRow) =>
|
|
||||||
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
|
||||||
|
|
||||||
// همگامسازی چند-انتخابیِ پروژهها: افزودن/حذف هر اختلاف با یک PATCH
|
|
||||||
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین میشود)
|
|
||||||
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
|
|
||||||
const current = assignedProjectsOf(u).map((p) => p.id);
|
|
||||||
const added = nextIds.filter((id) => !current.includes(id));
|
|
||||||
const removed = current.filter((id) => !nextIds.includes(id));
|
|
||||||
if (!added.length && !removed.length) return;
|
|
||||||
setBusyId(u.id);
|
|
||||||
try {
|
|
||||||
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
|
||||||
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
|
||||||
const names = nextIds
|
|
||||||
.map((id) => data.lookup.projects.find((p) => p.id === id)?.name)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("، ");
|
|
||||||
toast.success(
|
|
||||||
nextIds.length
|
|
||||||
? `پروژههای «${u.first_name} ${u.last_name}» بهروزرسانی شد: ${names} ✓`
|
|
||||||
: `«${u.first_name} ${u.last_name}» از همهی پروژهها جدا شد`,
|
|
||||||
);
|
|
||||||
await loadUsers();
|
|
||||||
await data.refresh();
|
|
||||||
refreshProcurementStore();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
|
||||||
await loadUsers(); // وضعیت واقعی سرور را برگردان
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- actions: warehouses ----------
|
|
||||||
|
|
||||||
const addWarehouse = async (projectId: string) => {
|
|
||||||
const name = (newWh[projectId] || "").trim();
|
|
||||||
if (!name) return toast.error("نام انبار الزامی است");
|
|
||||||
try {
|
|
||||||
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
|
|
||||||
setNewWh((s) => ({ ...s, [projectId]: "" }));
|
|
||||||
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
|
|
||||||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
|
||||||
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
|
||||||
const removeWarehouse = async (id: string) => {
|
|
||||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
|
||||||
try {
|
|
||||||
await apiV26.warehouses.delete(id);
|
|
||||||
toast.success("انبار حذف شد");
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
|
|
||||||
const pmOf = (projectId: string) =>
|
|
||||||
users.find(
|
|
||||||
(u) =>
|
|
||||||
u.role === "pm" &&
|
|
||||||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header
|
|
||||||
title="سازماندهی"
|
|
||||||
sub={inventorySubheader(user)}
|
|
||||||
backTo="/inventory"
|
|
||||||
backLabel="بازگشت به داشبورد"
|
|
||||||
/>
|
|
||||||
<PageLayout className="flex flex-col gap-3">
|
|
||||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
|
||||||
ℹ️ کاربران از Casdoor همگام میشوند و نقشها اینجا (فقط توسط ادمین) تعیین میشوند؛
|
|
||||||
مدیر تدارکات فقط پروژهی مدیران پروژه را تعیین/جابهجا میکند. پروژهها از سیستم تدارکات میآیند و
|
|
||||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- کاربران و مدیران پروژه ---------- */}
|
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => void syncUsers()}
|
|
||||||
disabled={syncing}
|
|
||||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingUsers ? (
|
|
||||||
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
|
||||||
) : !users.length ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
|
||||||
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) بهصورت خودکار ساخته میشوند؛
|
|
||||||
یا دکمهی «همگامسازی از Casdoor» را بزنید.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-[12.5px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-line text-right text-muted">
|
|
||||||
<th className="py-2 pe-3">نام</th>
|
|
||||||
<th className="py-2 pe-3">نقش</th>
|
|
||||||
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
|
|
||||||
<th className="py-2">عملیات</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
|
||||||
<td className="py-1.5 pe-3">
|
|
||||||
<b>{u.first_name} {u.last_name}</b>
|
|
||||||
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pe-3">
|
|
||||||
{canChangeRoles ? (
|
|
||||||
<select
|
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
|
||||||
value={u.role ?? ""}
|
|
||||||
disabled={busyId === u.id}
|
|
||||||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
|
||||||
>
|
|
||||||
<option value="">— بدون نقش —</option>
|
|
||||||
<option value="pm">مدیر پروژه</option>
|
|
||||||
<option value="procurement_manager">مدیر تدارکات</option>
|
|
||||||
<option value="admin">ادمین</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
|
|
||||||
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!canChangeRoles && u.role && (
|
|
||||||
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="min-w-[240px] py-1.5 pe-3 align-top">
|
|
||||||
{u.role === "pm" ? (
|
|
||||||
<ProjectMultiSelect
|
|
||||||
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
|
|
||||||
values={assignedProjectsOf(u).map((p) => p.id)}
|
|
||||||
onChange={(ids) => void syncUserProjects(u, ids)}
|
|
||||||
disabled={busyId === u.id}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11.5px] text-muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5">
|
|
||||||
{u.role === "pm" && u.project_id && (
|
|
||||||
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mt-1 text-[11px] text-muted">
|
|
||||||
«بدون نقش» = حذف مدیر پروژه (از همهی پروژهها هم جدا میشود). هر مدیر پروژه میتواند به
|
|
||||||
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا میکند و اتصال به یک پروژه، مدیرِ
|
|
||||||
قبلیِ آن پروژه را جابهجا میکند.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- پروژهها و انبارها ---------- */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
||||||
{data.lookup.projects.map((p) => {
|
|
||||||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
|
||||||
const pm = pmOf(p.id);
|
|
||||||
return (
|
|
||||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
|
|
||||||
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
|
|
||||||
{faNum(whs.length)} انبار
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
|
||||||
{pm ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
|
||||||
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
|
|
||||||
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
|
||||||
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
|
|
||||||
{!whs.length ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-2 flex flex-col gap-1.5">
|
|
||||||
{whs.map((w) => (
|
|
||||||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<b>{w.name}</b>
|
|
||||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
|
||||||
<button
|
|
||||||
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
|
|
||||||
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
|
|
||||||
title="داشبورد این انبار"
|
|
||||||
>
|
|
||||||
📊 داشبورد
|
|
||||||
</button>
|
|
||||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<input
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
|
|
||||||
placeholder="نام انبار جدید"
|
|
||||||
value={newWh[p.id] || ""}
|
|
||||||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
|
|
||||||
placeholder="توضیحات (اختیاری)"
|
|
||||||
value={newWhDesc[p.id] || ""}
|
|
||||||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
|
|
||||||
+ افزودن انبار
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,52 +9,42 @@ import { useInventoryData, projName } from "./useInventoryData";
|
|||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
|
||||||
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد)
|
// انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
|
||||||
export function WarehousesPage() {
|
export function WarehousesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
const isPM = user?.role === "pm";
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
|
||||||
|
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newDesc, setNewDesc] = useState("");
|
const [newDesc, setNewDesc] = useState("");
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDesc, setEditDesc] = useState("");
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
|
||||||
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازماندهی»
|
if (!canManage) {
|
||||||
if (isAdmin) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||||||
<div className="text-3xl">🔒</div>
|
<div className="text-3xl">🔒</div>
|
||||||
<h3 className="mt-2 text-[15px] font-bold">دسترسی ندارد</h3>
|
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
|
||||||
<p className="mt-1 text-[12.5px] text-muted">
|
<p className="mt-1 text-[12.5px] text-muted">
|
||||||
بخش «انبارها» مخصوص مدیران پروژه است. مدیریت انبارها از بخش «سازماندهی» انجام میشود.
|
دسترسی به این بخش برای حساب کاربری شما فعال نیست.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isPM) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const addWarehouse = async () => {
|
const addWarehouse = async () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (!name) return toast.error("نام انبار الزامی است");
|
if (!name) return toast.error("نام انبار الزامی است");
|
||||||
const projectId = user?.project_id;
|
const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id);
|
||||||
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
||||||
try {
|
try {
|
||||||
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
||||||
@@ -99,13 +89,26 @@ export function WarehousesPage() {
|
|||||||
backLabel="بازگشت به داشبورد"
|
backLabel="بازگشت به داشبورد"
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
{isPM && (
|
{canManage && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-3">
|
<div className="rounded-xl border border-line bg-panel p-3">
|
||||||
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!isSupervisor && data.lookup.projects.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||||
|
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
>
|
||||||
|
{data.lookup.projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
پروژه: {p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||||
placeholder="نام انبار (مثلاً انبار موقت)"
|
placeholder="نام انبار (مثلاً انبار مرکزی)"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +151,7 @@ export function WarehousesPage() {
|
|||||||
>
|
>
|
||||||
📊 مشاهده داشبورد انبار
|
📊 مشاهده داشبورد انبار
|
||||||
</button>
|
</button>
|
||||||
{isPM && (
|
{canManage && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
||||||
@@ -162,7 +165,7 @@ export function WarehousesPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editId === w.id && isPM && (
|
{editId === w.id && canManage && (
|
||||||
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
|
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||||
|
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type RowDraft } from "./StockTable";
|
||||||
|
|
||||||
|
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||||
|
|
||||||
|
interface FoundTableProps {
|
||||||
|
items: LossItem[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocal(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||||
|
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد پیداشده"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// ============================================================
|
||||||
|
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||||
|
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||||
|
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||||
|
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||||
|
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type StockRecord } from "./StockTable";
|
||||||
|
|
||||||
|
interface ProjectTransferListProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||||
|
errors: Record<string, string>;
|
||||||
|
emptyMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[600px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد انتقال</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد میشود.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد انتقال"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «اقلام ثبتشده در این نشست» برای موجودی اولیه
|
||||||
|
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظهی همان صفحه.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
export interface PurchaseSessionEntry {
|
||||||
|
material: string;
|
||||||
|
brand: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
qty: number;
|
||||||
|
unitLabel: string;
|
||||||
|
time: string; // HH:MM آمادهی نمایش
|
||||||
|
whName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseSessionLogProps {
|
||||||
|
entries: PurchaseSessionEntry[];
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
|
||||||
|
if (!entries.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||||
|
<h3 className="text-[13px] font-bold">
|
||||||
|
🧾 اقلام ثبتشده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
|
||||||
|
>
|
||||||
|
پاک کردن لیست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دسکتاپ: جدول فشرده */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 px-3">متریال</th>
|
||||||
|
<th className="py-2 px-3">برند</th>
|
||||||
|
<th className="py-2 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2 px-3">تعداد</th>
|
||||||
|
<th className="py-2 px-3">ساعت</th>
|
||||||
|
<th className="py-2 px-3">انبار</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
|
||||||
|
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.time}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: ردیفهای فشرده */}
|
||||||
|
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
|
||||||
|
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted">
|
||||||
|
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ============================================================
|
||||||
|
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||||
|
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||||
|
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||||
|
|
||||||
|
interface ReturnFilterBarProps {
|
||||||
|
preset: DatePreset;
|
||||||
|
onPreset: (p: DatePreset) => void;
|
||||||
|
customFrom: string | null;
|
||||||
|
customTo: string | null;
|
||||||
|
onCustomFrom: (iso: string | null) => void;
|
||||||
|
onCustomTo: (iso: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPreset(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||||
|
preset === p.id
|
||||||
|
? "bg-accent font-bold text-white shadow-xs"
|
||||||
|
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset === "custom" && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول برداشتهای بازه برای بخش «برگشت»
|
||||||
|
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
|
||||||
|
// برگشت محاسبهشده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
|
||||||
|
// ورودیها کلید-محورند (کلید = tx-id) تا حذف ردیفهای موفق، بقیه را
|
||||||
|
// بههم نریزد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum, fmtDateTime } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
interface ReturnTableProps {
|
||||||
|
withdrawals: InventoryTransaction[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
noteOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
onNote: (id: string, v: string) => void;
|
||||||
|
remainders: Map<string, number>;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشتها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[760px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">تاریخ</th>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">برداشت</th>
|
||||||
|
<th className="py-2.5 px-3">مانده قابل برگشت</th>
|
||||||
|
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
|
||||||
|
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">
|
||||||
|
«مانده قابل برگشت» = مقدار برداشت − برگشتهای ثبتشده همان ردیف. سقف ورودی همین مانده است.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{w.specs && Object.keys(w.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
|
||||||
|
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||||
|
<span>{fmtDateTime(w.transaction_date)}</span>
|
||||||
|
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
|
||||||
|
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="مقدار برگشت"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویرایشگر مشخصههای فنی Key-Value (موجودی اولیه)
|
||||||
|
// خروجی: jsonb استاندارد {عنوان: مقدار} همخوان با specs_hash بکاند.
|
||||||
|
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface SpecKvRow { key: string; value: string; }
|
||||||
|
|
||||||
|
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = r.key.trim();
|
||||||
|
if (k) out[k] = r.value.trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecsKvEditorProps {
|
||||||
|
rows: SpecKvRow[];
|
||||||
|
onChange: (rows: SpecKvRow[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
|
||||||
|
const update = (i: number, patch: Partial<SpecKvRow>) =>
|
||||||
|
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="عنوان مشخصه (مثلاً ضخامت)"
|
||||||
|
value={r.key}
|
||||||
|
onChange={(e) => update(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[12px] text-muted">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="مقدار (مثلاً ۲ میلیمتر)"
|
||||||
|
value={r.value}
|
||||||
|
onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
|
||||||
|
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||||
|
title="حذف این مشخصه"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange([...rows, { key: "", value: "" }])}
|
||||||
|
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||||||
|
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||||||
|
//
|
||||||
|
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
|
||||||
|
// موبایل (<640px): کارتهای عمودی لمسی با فیلد بزرگ ورود مقدار.
|
||||||
|
// هر بخش ستونهای نمایش یکسان را از اینجا میگیرد و فقط رفتار ورودی
|
||||||
|
// (تعداد ساده یا شمارششده) را با isCount انتخاب میکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface StockRecord {
|
||||||
|
id: string;
|
||||||
|
material: { id: string; name: string };
|
||||||
|
brand: { id: string; name: string } | null;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
quantity_number: number;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowDraft { key: string; qty: string; note: string; }
|
||||||
|
|
||||||
|
export function specsLine(specs: Record<string, string> | undefined): string {
|
||||||
|
return (
|
||||||
|
Object.entries(specs || {})
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(" · ") || "—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockTableProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
/** شمارش موجودی: placeholder «شمارششده» و پیشنمایش مقدار فعلی هنگام خالی بودن */
|
||||||
|
isCount?: boolean;
|
||||||
|
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است میتوان پنهان کرد */
|
||||||
|
showMaterial?: boolean;
|
||||||
|
emptyMsg: string;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qtyPlaceholder = isCount ? "شمارششده" : "۰";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[560px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
|
||||||
|
<tr className="text-right text-muted">
|
||||||
|
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
|
||||||
|
{showMaterial && (
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
)}
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتهای عمودی لمسی */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// ============================================================
|
||||||
|
// منطق بخش «برگشت» — واکشی برداشتها/برگشتها و محاسبه مانده قابل برگشت
|
||||||
|
//
|
||||||
|
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمیشود؛
|
||||||
|
// مانده هر برداشت = مقدار برداشت − مجموع برگشتهای متصل به آن.
|
||||||
|
// اتصال دو راه دارد:
|
||||||
|
// ۱) برگشتهای جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
|
||||||
|
// ۲) برگشتهای قدیمی بدون REF (ثبتشده در همان انبار مبدأ) → تخصیص FIFO
|
||||||
|
// بین برداشتهای همان ردیف انبار (متریال+برند+مشخصات+واحد).
|
||||||
|
// همهی جمع/تفریقها با round4 گرد میشوند تا خطای اعشار جاوااسکریپت
|
||||||
|
// (مثل 0.30000000000000004) در ماندهها و سقفها نشت نکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { apiV26 } from "../../../lib/api";
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
|
||||||
|
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همهی محاسبات مانده */
|
||||||
|
export function round4(n: number): number {
|
||||||
|
return Math.round(n * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تاریخ — کلید روز میلادی محلی و بازههای پیشفرض
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function gregKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d: Date, n: number): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
|
||||||
|
|
||||||
|
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
|
||||||
|
{ id: "today", label: "امروز" },
|
||||||
|
{ id: "yesterday", label: "دیروز" },
|
||||||
|
{ id: "7d", label: "۷ روز اخیر" },
|
||||||
|
{ id: "30d", label: "۳۰ روز اخیر" },
|
||||||
|
{ id: "custom", label: "بازه دلخواه" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** بازهی سرور-ساید برای هر پیشفرض؛ date_to روزِ (inclusive بکاند: < date_to + 24h) */
|
||||||
|
export function presetRange(
|
||||||
|
preset: DatePreset,
|
||||||
|
customFrom?: string,
|
||||||
|
customTo?: string,
|
||||||
|
): { date_from: string; date_to: string } {
|
||||||
|
const today = new Date();
|
||||||
|
switch (preset) {
|
||||||
|
case "today":
|
||||||
|
return { date_from: gregKey(today), date_to: gregKey(today) };
|
||||||
|
case "yesterday": {
|
||||||
|
const y = gregKey(addDays(today, -1));
|
||||||
|
return { date_from: y, date_to: y };
|
||||||
|
}
|
||||||
|
case "7d":
|
||||||
|
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
|
||||||
|
case "30d":
|
||||||
|
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
|
||||||
|
case "custom":
|
||||||
|
return {
|
||||||
|
date_from: customFrom || gregKey(today),
|
||||||
|
date_to: customTo || customFrom || gregKey(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تگ رفرنس [REF:<uuid>]
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
|
||||||
|
|
||||||
|
export function parseRef(note?: string | null): string | null {
|
||||||
|
if (!note) return null;
|
||||||
|
const m = note.match(REF_RE);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
|
||||||
|
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
|
||||||
|
const tag = `[REF:${withdrawalTxId}]`;
|
||||||
|
const note = (userNote || "").trim();
|
||||||
|
return note ? `${note} ${tag}` : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// واکشی صفحهبندیشده (هر صفحه ۱۰۰ — سقف بکاند)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const MAX_PAGES = 10; // سقف محافظهکارانه: ۱۰۰۰ رکورد
|
||||||
|
|
||||||
|
export async function fetchWithdrawalsPage(
|
||||||
|
sourceWhId: string,
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
page: number,
|
||||||
|
): Promise<{ rows: InventoryTransaction[]; total: number }> {
|
||||||
|
return apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: sourceWhId,
|
||||||
|
transaction_type: "WITHDRAWAL",
|
||||||
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی برگشتهای پروژه از ابتدای بازه (برای تطبیق REF) — صفحهبهصفحه تا سقف */
|
||||||
|
export async function fetchProjectReturns(
|
||||||
|
dateFrom: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
transaction_type: "RETURN",
|
||||||
|
date_from: dateFrom,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی تراکنشهای یک نوع در یک انبار (برای محاسبه مانده مفقودیها) */
|
||||||
|
export async function fetchAllTypeInWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
transactionType: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: warehouseId,
|
||||||
|
transaction_type: transactionType,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// مانده قابل برگشت
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتبشده) + واحد — همارز specs_hash بکاند */
|
||||||
|
function rowKey(t: InventoryTransaction): string {
|
||||||
|
const specs = Object.keys(t.specs || {})
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${String((t.specs || {})[k])}`)
|
||||||
|
.join("&");
|
||||||
|
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مانده قابل برگشت هر برداشت را حساب میکند.
|
||||||
|
* - برگشتهای REF-دار: مستقیماً از برداشت مقصد کم میشوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
|
||||||
|
* - برگشتهای REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته میشوند (به برداشت خودشان تعلق دارند).
|
||||||
|
* - برگشتهای قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشتهای همردیف تخصیص مییابند.
|
||||||
|
*/
|
||||||
|
export function computeRemainders(
|
||||||
|
withdrawals: InventoryTransaction[],
|
||||||
|
returns: InventoryTransaction[],
|
||||||
|
sourceWhId: string,
|
||||||
|
): Map<string, number> {
|
||||||
|
const rem = new Map<string, number>();
|
||||||
|
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
|
||||||
|
|
||||||
|
const inList = new Set(withdrawals.map((w) => w.id));
|
||||||
|
const legacy: InventoryTransaction[] = [];
|
||||||
|
|
||||||
|
for (const r of returns) {
|
||||||
|
const qty = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
const ref = parseRef(r.note);
|
||||||
|
if (ref) {
|
||||||
|
if (inList.has(ref)) {
|
||||||
|
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
|
||||||
|
}
|
||||||
|
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
|
||||||
|
} else {
|
||||||
|
legacy.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// تخصیص FIFO برگشتهای قدیمیِ همانبار مبدأ بین برداشتهای همردیف (قدیمی→جدید)
|
||||||
|
const buckets = new Map<string, InventoryTransaction[]>();
|
||||||
|
for (const w of withdrawals) {
|
||||||
|
const k = rowKey(w);
|
||||||
|
const list = buckets.get(k);
|
||||||
|
if (list) list.push(w);
|
||||||
|
else buckets.set(k, [w]);
|
||||||
|
}
|
||||||
|
for (const list of buckets.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const r of legacy) {
|
||||||
|
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
|
||||||
|
const list = buckets.get(rowKey(r));
|
||||||
|
if (!list) continue;
|
||||||
|
let q = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
for (const w of list) {
|
||||||
|
if (q <= 0) break;
|
||||||
|
const cur = rem.get(w.id) ?? 0;
|
||||||
|
if (cur <= 0) continue;
|
||||||
|
const take = Math.min(cur, q);
|
||||||
|
rem.set(w.id, round4(cur - take));
|
||||||
|
q = round4(q - take);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// کف صفر (محافظت در برابر دادههای ناسازگار قدیمی)
|
||||||
|
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
|
||||||
|
return rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مانده مفقودیِ باقیمانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
|
||||||
|
export function computeLossRemainders(
|
||||||
|
losses: InventoryTransaction[],
|
||||||
|
founds: InventoryTransaction[],
|
||||||
|
): { tx: InventoryTransaction; remaining: number }[] {
|
||||||
|
const foundByKey = new Map<string, number>();
|
||||||
|
for (const f of founds) {
|
||||||
|
const k = rowKey(f);
|
||||||
|
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
|
||||||
|
}
|
||||||
|
return losses
|
||||||
|
.map((tx) => {
|
||||||
|
const loss = Math.abs(tx.quantity_number || 0);
|
||||||
|
const found = foundByKey.get(rowKey(tx)) || 0;
|
||||||
|
return { tx, remaining: Math.max(0, round4(loss - found)) };
|
||||||
|
})
|
||||||
|
.filter((x) => x.remaining > 0);
|
||||||
|
}
|
||||||
@@ -10,20 +10,22 @@ export function useLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
pm: "مدیر پروژه",
|
|
||||||
procurement_manager: "مدیر تدارکات",
|
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
// زیرعنوان پویای سربرگ پنل انبار:
|
// زیرعنوان پویای سربرگ پنل انبار:
|
||||||
// [نام کاربر] | [نقش] – [پروژه / همه پروژهها]
|
// [نام کاربر] | [نقش] – [پروژه / همه پروژهها]
|
||||||
// مدیر پروژه → نام پروژهی خودش؛ مدیر تدارکات/ادمین → «همه پروژهها».
|
// سرپرست پروژه → نام پروژهی خودش؛ مدیران/ادمین → «همه پروژهها».
|
||||||
export function inventorySubheader(user: { display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
|
export function inventorySubheader(user: { username?: string; casdoor_id?: string; display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
|
||||||
if (!user) return "در حال بارگذاری…";
|
if (!user) return "در حال بارگذاری…";
|
||||||
const name = user.display_name || `${user.first_name} ${user.last_name}`.trim() || "کاربر";
|
const name = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
|
||||||
|
const uname = user.username || user.casdoor_id || "-";
|
||||||
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
||||||
const proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژهها";
|
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
|
||||||
return `${name} | ${role} – ${proj}`;
|
return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخشها بالای صفحه هستند و
|
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخشها بالای صفحه هستند و
|
||||||
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
|
// منوی همبرگری سرپرست پروژه در پنل انبار
|
||||||
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمهی خروجِ تکی هم حذف شده).
|
|
||||||
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
||||||
const items: MenuItem[] = [];
|
const items: MenuItem[] = [];
|
||||||
if (onLogout) {
|
if (onLogout) {
|
||||||
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
// آیتمهای بخشهای داخلی انبار
|
||||||
//
|
|
||||||
// دسترسیها (v2.6):
|
|
||||||
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازماندهی —
|
|
||||||
// بدون «تغییر موجودی» و بدون «انبارها».
|
|
||||||
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
|
|
||||||
// بدون «سازماندهی».
|
|
||||||
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
||||||
const isAdmin = role === "admin" || role === "procurement_manager";
|
const isAdmin = role === "admin";
|
||||||
const isPM = role === "pm";
|
const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
|
||||||
|
const isSupervisor = role === "supervisor" || isAdmin;
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
|
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
|
||||||
];
|
];
|
||||||
if (isPM) {
|
// فقط سرپرست پروژه و ادمین میتوانند تغییر موجودی بدهند
|
||||||
|
if (isSupervisor) {
|
||||||
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
|
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
|
||||||
}
|
}
|
||||||
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران و سرپرست پروژه
|
||||||
if (isPM) {
|
if (isManager || isSupervisor) {
|
||||||
|
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
||||||
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
||||||
}
|
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||||
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
|
||||||
if (isAdmin) {
|
|
||||||
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
|
||||||
}
|
|
||||||
if (isAdmin) {
|
|
||||||
items.push({ divider: true });
|
|
||||||
items.push({ label: "بازگشت به دفتر", to: "/" });
|
|
||||||
}
|
}
|
||||||
if (onLogout) {
|
if (onLogout) {
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export interface InventoryData {
|
|||||||
|
|
||||||
export function useInventoryData(): InventoryData {
|
export function useInventoryData(): InventoryData {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isPM = user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
const projectId = isPM ? user.project_id || undefined : undefined;
|
const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
|
||||||
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
import { api, apiV26, type UserRow } from "../../lib/api";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
import { refreshProcurementStore, useProjects } from "../../hooks";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
|
|
||||||
|
type Role = "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
|
};
|
||||||
|
|
||||||
|
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها و پنلها
|
||||||
|
export function OrganizationPage() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const storeProjects = useProjects();
|
||||||
|
const [directProjects, setDirectProjects] = useState<Project[]>([]);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
|
const canChangeRoles = canManage;
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingProjects(true);
|
||||||
|
const list = await api.projects.list();
|
||||||
|
if (list && Array.isArray(list)) {
|
||||||
|
setDirectProjects(list);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback silently
|
||||||
|
} finally {
|
||||||
|
setLoadingProjects(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingUsers(true);
|
||||||
|
const list = await apiV26.users.list();
|
||||||
|
setUsers(list);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canManage) {
|
||||||
|
void loadUsers();
|
||||||
|
void fetchProjects();
|
||||||
|
}
|
||||||
|
}, [canManage, loadUsers, fetchProjects]);
|
||||||
|
|
||||||
|
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
|
||||||
|
|
||||||
|
const syncUsers = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const { users: synced, created } = await apiV26.users.sync();
|
||||||
|
setUsers(synced);
|
||||||
|
toast.success(
|
||||||
|
created > 0
|
||||||
|
? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
|
||||||
|
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||||||
|
);
|
||||||
|
refreshProcurementStore();
|
||||||
|
void fetchProjects();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header title="سازماندهی" backTo="/" backLabel="بازگشت به دفتر" />
|
||||||
|
<PageLayout>
|
||||||
|
<div className="text-[13px] text-muted">این بخش فقط برای ادمین، مدیر تدارکات و مدیر پروژه است.</div>
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- actions: users ----------
|
||||||
|
|
||||||
|
const changeRole = async (u: UserRow, role: Role) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
try {
|
||||||
|
await apiV26.users.updateRole(u.id, role);
|
||||||
|
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
|
||||||
|
await loadUsers();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای ----------
|
||||||
|
const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
const next = { ...(u.panels ?? {}) };
|
||||||
|
if (level) next[panel] = level;
|
||||||
|
else delete next[panel];
|
||||||
|
try {
|
||||||
|
await apiV26.users.setPanels(u.id, next);
|
||||||
|
toast.success(`دسترسی «${u.first_name} ${u.last_name}» بهروزرسانی شد ✓`);
|
||||||
|
await loadUsers();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در ثبت دسترسی پنل");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMemberProjects = async (u: UserRow, nextIds: string[]) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
try {
|
||||||
|
await apiV26.users.setPanelProjects(u.id, nextIds);
|
||||||
|
toast.success(`عضویت پروژهای «${u.first_name} ${u.last_name}» بهروزرسانی شد ✓`);
|
||||||
|
await loadUsers();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در ثبت عضویت پروژهها");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelLabel = (level: string | undefined) =>
|
||||||
|
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
|
||||||
|
|
||||||
|
// سرپرستان و مسئولان تخصیصیافته به یک پروژه
|
||||||
|
const supervisorsOf = (projectId: string) =>
|
||||||
|
users.filter(
|
||||||
|
(u) =>
|
||||||
|
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
|
||||||
|
(u.projects?.some((p) => p.id === projectId) ||
|
||||||
|
u.project_id === projectId ||
|
||||||
|
u.member_project_ids?.includes(projectId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header
|
||||||
|
title="سازماندهی"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="organization" />}
|
||||||
|
/>
|
||||||
|
<PageLayout className="flex flex-col gap-5">
|
||||||
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
|
||||||
|
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگامسازی» اضافه میشوند. نقشهای سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین میگردد.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقشها و سطوح دسترسی پنلها ---------- */}
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[14px] font-bold">👥 کاربران، نقشها و سطوح دسترسی پنلها</h3>
|
||||||
|
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
تعیین نقش سازمانی، تخصیص پنلها (تدارکات، انبار، PM) و مشخص نمودن پروژههای عضویت کاربر
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void syncUsers()}
|
||||||
|
disabled={syncing}
|
||||||
|
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||||
|
) : !users.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
|
||||||
|
کاربری ثبت نشده است — دکمهی «همگامسازی از Casdoor» را بزنید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[12.5px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line text-right text-muted">
|
||||||
|
<th className="py-2.5 pe-3">نام و مشخصات</th>
|
||||||
|
<th className="py-2.5 pe-3">نقش سازمانی</th>
|
||||||
|
<th className="py-2.5 pe-3">پنل تدارکات</th>
|
||||||
|
<th className="py-2.5 pe-3">پنل انبار</th>
|
||||||
|
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
|
||||||
|
<th className="py-2.5">پروژههای عضویت (محدوده سطح محدود)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const isAdmin = u.role === "admin";
|
||||||
|
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
|
||||||
|
{/* ۱. نام و نام کاربری */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-foreground">
|
||||||
|
{u.first_name} {u.last_name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted">
|
||||||
|
({u.username || "—"})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{busyId === u.id && (
|
||||||
|
<span className="text-[10px] text-accent animate-pulse font-medium">
|
||||||
|
در حال ذخیره تغییرات…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۲. نقش سازمانی */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.role ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||||
|
>
|
||||||
|
<option value="">— بدون نقش —</option>
|
||||||
|
<option value="admin">ادمین</option>
|
||||||
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
|
<option value="pm">مدیر پروژه</option>
|
||||||
|
<option value="supervisor">سرپرست پروژه</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
|
||||||
|
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۳. پنل تدارکات */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||||
|
) : canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.panels?.procurement ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
|
||||||
|
>
|
||||||
|
<option value="">— بدون تخصیص —</option>
|
||||||
|
<option value="full">کامل</option>
|
||||||
|
<option value="limited">محدود (فقط استعلام)</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۴. پنل انبار */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||||
|
) : canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.panels?.inventory ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
|
||||||
|
>
|
||||||
|
<option value="">— بدون تخصیص —</option>
|
||||||
|
<option value="full">کامل (بدون تغییر موجودی)</option>
|
||||||
|
<option value="limited">محدود (با تغییر موجودی)</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۵. پنل مدیریت پروژه (PM) */}
|
||||||
|
<td className="py-2.5 pe-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||||
|
) : canChangeRoles ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
value={u.panels?.pm ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
|
||||||
|
>
|
||||||
|
<option value="">— بدون تخصیص —</option>
|
||||||
|
<option value="full">کامل</option>
|
||||||
|
<option value="limited">محدود</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ۶. پروژههای عضویت */}
|
||||||
|
<td className="py-2.5">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژهها</span>
|
||||||
|
) : hasPanel ? (
|
||||||
|
<div className="w-72">
|
||||||
|
<ProjectMultiSelect
|
||||||
|
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||||
|
values={u.member_project_ids ?? []}
|
||||||
|
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||||
|
<p className="text-[11.5px] text-muted">
|
||||||
|
مشاهده وضعیت پروژهها و سرپرستان یا اعضای تخصیصیافته به هر پروژه
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{loadingProjects && (
|
||||||
|
<span className="text-[11px] text-muted animate-pulse">در حال بهروزرسانی پروژهها…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingProjects && !projects.length ? (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
|
||||||
|
در حال دریافت اطلاعات پروژهها…
|
||||||
|
</div>
|
||||||
|
) : !projects.length ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
|
||||||
|
<span>پروژهای برای نمایش یافت نشد یا در دسترس نیست.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void fetchProjects()}
|
||||||
|
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
🔄 تلاش مجدد برای دریافت پروژهها
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((p) => {
|
||||||
|
const sups = supervisorsOf(p.id);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
|
||||||
|
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
|
||||||
|
{p.status || "پروژه فعال"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.description && (
|
||||||
|
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-line/50">
|
||||||
|
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
|
||||||
|
{!sups.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
|
||||||
|
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{sups.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
|
||||||
|
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
|
||||||
|
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنلها ---------- */}
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="flex items-center gap-2 text-[15px] font-bold">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
|
📖
|
||||||
|
</span>
|
||||||
|
توضیحات پنلها و راهنمای سطوح دسترسی
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[12px] text-muted">
|
||||||
|
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنلهای سامانه
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
مدل دسترسی ماژولار مبتنی بر پنل
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
{/* کارت پنل تدارکات */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
|
||||||
|
🛒
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
|
||||||
|
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
|
||||||
|
خرید و بازرگانی
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>دسترسی کامل مدیریتی به تمامی بخشهای تدارکات</li>
|
||||||
|
<li>ثبت و ویرایش پیشفاکتورها، شرایط پرداخت و فاکتورها</li>
|
||||||
|
<li>مدیریت تأمینکنندگان، اقلام و تصمیمگیری خرید در همه پروژهها</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>فقط امکان ثبت درخواست استعلام قیمت و پیشفاکتور اولیه</li>
|
||||||
|
<li>محدود منحصراً به پروژههایی که کاربر در آنها عضویت دارد</li>
|
||||||
|
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمتها یا پروژههای غیرعضو</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کارت پنل انبار */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
|
||||||
|
📦
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
|
||||||
|
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
انبارداری و موجودی
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژهها</li>
|
||||||
|
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارشگیری کلان</li>
|
||||||
|
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>دسترسی فقط به انبارهای پروژههای عضویت کاربر</li>
|
||||||
|
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
|
||||||
|
<li>تفکیک دقیق موجودی و تراکنشهای انبار بر اساس پروژه</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کارت پنل مدیریت پروژه (PM) */}
|
||||||
|
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
|
||||||
|
📊
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
|
||||||
|
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
|
||||||
|
کنترل پروژه و اجرا
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>مدیریت جامع ساختار شکست کار (WBS)، زمانبندی و گانت چارت تعاملی</li>
|
||||||
|
<li>تعریف و ویرایش فعالیتها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
|
||||||
|
<li>پایش شاخصهای پیشرفت زمانی و مالی (EVM) و گزارشهای تحلیلی</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||||
|
<li>مشاهده فعالیتها و تسکهای مربوط به پروژههای عضویت کاربر</li>
|
||||||
|
<li>بهروزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسکها</li>
|
||||||
|
<li>تکمیل چکلیستهای کنترل کیفیت (QC) و ثبت گزارشهای اجرایی روزانه</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
|
||||||
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||||
|
<span className="text-[16px] leading-none">👑</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
|
||||||
|
<p className="mt-0.5 text-muted leading-relaxed">
|
||||||
|
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنلها و پروژهها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنلها ندارد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||||
|
<span className="text-[16px] leading-none">🎯</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-foreground">کارکرد «پروژههای عضویت»:</span>
|
||||||
|
<p className="mt-0.5 text-muted leading-relaxed">
|
||||||
|
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه دادهها و اختیارات وی دقیقاً به پروژههای انتخابی در ستون «پروژههای عضویت» محدود خواهد شد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
|
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban, MapPin, UserCircle2, Trash2, Plus, X } from "lucide-react";
|
||||||
import type { Project, Quote, Store } from "../../lib/types";
|
import type { Project, Quote, Store } from "../../lib/types";
|
||||||
import { nextId } from "../../lib/api";
|
|
||||||
import {
|
import {
|
||||||
useAddProject,
|
useAddProject,
|
||||||
|
useDeleteProject,
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
useQuotes,
|
useQuotes,
|
||||||
@@ -25,6 +25,7 @@ import { faNum, fmtDate } from "../../lib/formatters";
|
|||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||||
return quotes.filter(
|
return quotes.filter(
|
||||||
@@ -39,23 +40,14 @@ function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
|
||||||
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
|
||||||
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
|
||||||
// برای این پروژه همچنان «پیشنهاد شده» است.
|
|
||||||
function acceptedForProject(q: Quote, name: string): boolean {
|
function acceptedForProject(q: Quote, name: string): boolean {
|
||||||
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
|
||||||
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
|
||||||
function hasTargets(q: Quote): boolean {
|
function hasTargets(q: Quote): boolean {
|
||||||
return (q.line_items || []).some((li) => !!li.target_project);
|
return (q.line_items || []).some((li) => !!li.target_project);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اقلام مربوط به این پروژه در یک quote:
|
|
||||||
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
|
||||||
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
|
||||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
const targeted = items.filter((li) => li.target_project?.name === name);
|
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||||
@@ -73,6 +65,97 @@ function daysSince(iso: string | null): number | null {
|
|||||||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- مودال افزودن پروژه -----
|
||||||
|
function AddProjectModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (p: Omit<Project, "id">) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [projectManager, setProjectManager] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg border border-line bg-surface px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40";
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setName(""); setLocation(""); setProjectManager(""); setDescription(""); setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => { reset(); onClose(); };
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!name.trim()) { setError("نام پروژه الزامی است"); return; }
|
||||||
|
setError("");
|
||||||
|
onSubmit({ name: name.trim(), location: location.trim(), project_manager: projectManager.trim(), description: description.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={handleClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-2xl bg-panel shadow-2xl border border-line p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-base font-bold">افزودن پروژه جدید</h2>
|
||||||
|
<button type="button" onClick={handleClose} className="text-muted hover:text-on-surface transition-colors">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{error && <p className="rounded-lg bg-red-50 border border-red-200 text-red-700 px-3 py-2 text-xs">{error}</p>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">نام پروژه *</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
|
||||||
|
placeholder="مثلاً: برج مسکونی آسمان"
|
||||||
|
className={inputCls}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">محل پروژه</label>
|
||||||
|
<input value={location} onChange={(e) => setLocation(e.target.value)} placeholder="شهر / منطقه" className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">مدیر / سرپرست پروژه</label>
|
||||||
|
<input value={projectManager} onChange={(e) => setProjectManager(e.target.value)} placeholder="نام سرپرست" className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">توضیحات</label>
|
||||||
|
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="توضیحات تکمیلی پروژه..." className={`${inputCls} resize-none`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={handleClose} className="h-9 rounded-lg border border-line px-4 text-sm text-muted hover:bg-surface transition-colors">انصراف</button>
|
||||||
|
<button type="button" disabled={loading} onClick={handleSubmit} className="h-9 rounded-lg bg-accent px-4 text-sm font-semibold text-white hover:bg-accent/90 disabled:opacity-50 transition-colors">
|
||||||
|
{loading ? "در حال ذخیره..." : "افزودن پروژه"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
@@ -82,54 +165,53 @@ export function ProjectsPage() {
|
|||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const { standardConversions } = useUnits();
|
const { standardConversions } = useUnits();
|
||||||
const addProject = useAddProject();
|
const addProject = useAddProject();
|
||||||
|
const deleteProject = useDeleteProject();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
||||||
|
|
||||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||||
const [newName, setNewName] = useState("");
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
|
||||||
const addProj = () => {
|
const handleAddProject = async (data: Omit<Project, "id">) => {
|
||||||
const name = newName.trim();
|
const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
|
||||||
if (!name) return;
|
if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
|
||||||
const dup = projects.some((p) => normalize(p.name) === normalize(name));
|
try {
|
||||||
if (dup) {
|
await addProject.mutateAsync({ id: "", ...data });
|
||||||
toast.error("این پروژه قبلاً ثبت شده است.");
|
setShowAddModal(false);
|
||||||
return;
|
setOpenProject(data.name);
|
||||||
|
toast.success("پروژه با موفقیت اضافه شد.");
|
||||||
|
} catch {
|
||||||
|
toast.error("خطا در ثبت پروژه");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async (p: Project, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm(`آیا از حذف پروژه «${p.name}» مطمئن هستید؟`)) return;
|
||||||
|
try {
|
||||||
|
await deleteProject.mutateAsync(p.id);
|
||||||
|
toast.success("پروژه حذف شد.");
|
||||||
|
if (openProject === p.name) setOpenProject(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || "این پروژه دارای پیشنهاد است و قابل حذف نیست");
|
||||||
}
|
}
|
||||||
const p: Project = { id: nextId("p", projects), name };
|
|
||||||
void addProject.mutateAsync(p);
|
|
||||||
setNewName("");
|
|
||||||
setOpenProject(name);
|
|
||||||
toast.success("پروژه افزوده شد.");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="پروژهها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
|
<Header title="پروژهها" sub="مدیریت پروژههای مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
<div className="add-panel">
|
<div className="add-panel flex items-center justify-between">
|
||||||
<h4>+ افزودن پروژهی جدید</h4>
|
<h4>پروژههای سامانه</h4>
|
||||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
{canManage && (
|
||||||
<div className="field">
|
<Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
|
||||||
<label>نام پروژه *</label>
|
<Plus size={15} />
|
||||||
<input
|
افزودن پروژه
|
||||||
type="text"
|
</Button>
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
)}
|
||||||
placeholder="مثلاً برج آیتا - فاز ۳"
|
|
||||||
autoComplete="off"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addProj();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addProj}>افزودن پروژه</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!projects.length && !quotes.length ? (
|
{!projects.length && !quotes.length ? (
|
||||||
@@ -141,16 +223,37 @@ export function ProjectsPage() {
|
|||||||
const open = openProject === p.name;
|
const open = openProject === p.name;
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex flex-col gap-1.5">
|
<div key={p.id} className="flex flex-col gap-1.5">
|
||||||
<button
|
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
|
||||||
type="button"
|
<button
|
||||||
className={`proj-item ${open ? "open" : ""}`}
|
type="button"
|
||||||
onClick={() => setOpenProject(open ? null : p.name)}
|
className="flex flex-1 items-center gap-2 min-w-0 text-right"
|
||||||
>
|
onClick={() => setOpenProject(open ? null : p.name)}
|
||||||
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
>
|
||||||
<span className="font-medium">{p.name}</span>
|
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
||||||
<span className="proj-count">{faNum(count)} پیشنهاد</span>
|
<span className="font-medium truncate">{p.name}</span>
|
||||||
</button>
|
{p.location && (
|
||||||
{/* جزئیات دقیقاً زیر همان پروژه باز میشود */}
|
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
|
||||||
|
<MapPin size={10} />{p.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.project_manager && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
|
||||||
|
<UserCircle2 size={10} />{p.project_manager}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="proj-count ms-auto shrink-0">{faNum(count)} پیشنهاد</span>
|
||||||
|
</button>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDeleteProject(p, e)}
|
||||||
|
title="حذف پروژه"
|
||||||
|
className="text-muted hover:text-red-600 transition-colors p-1 rounded shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
|
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -158,6 +261,13 @@ export function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
|
<AddProjectModal
|
||||||
|
open={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSubmit={handleAddProject}
|
||||||
|
loading={addProject.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,7 +276,6 @@ export function ProjectsPage() {
|
|||||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||||
const quotes = projectQuotes(store.quotes, name);
|
const quotes = projectQuotes(store.quotes, name);
|
||||||
|
|
||||||
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
|
||||||
const itemsTotal = (q: Quote) =>
|
const itemsTotal = (q: Quote) =>
|
||||||
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||||
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||||
@@ -196,10 +305,6 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// دستهبندی ایزوله per-project:
|
|
||||||
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
|
||||||
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
|
||||||
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
|
||||||
const offered = quotes
|
const offered = quotes
|
||||||
.filter(
|
.filter(
|
||||||
(q) =>
|
(q) =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
||||||
import { materialLabel } from "../../lib/quoteUtils";
|
import { materialLabel } from "../../lib/quoteUtils";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
@@ -26,6 +27,8 @@ export function CategoryPage() {
|
|||||||
const addSupplier = useAddSupplier();
|
const addSupplier = useAddSupplier();
|
||||||
const removeSupplier = useRemoveSupplier();
|
const removeSupplier = useRemoveSupplier();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
|
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
|
||||||
|
|
||||||
@@ -121,7 +124,7 @@ export function CategoryPage() {
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
s={s}
|
s={s}
|
||||||
topic={cat}
|
topic={cat}
|
||||||
deletable
|
deletable={canManage}
|
||||||
onDelete={removeSup}
|
onDelete={removeSup}
|
||||||
materials={supMats.get(s.id) || []}
|
materials={supMats.get(s.id) || []}
|
||||||
avgLeadTime={supLeadTimes.get(s.id)}
|
avgLeadTime={supLeadTimes.get(s.id)}
|
||||||
@@ -130,10 +133,11 @@ export function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="add-panel">
|
{canManage && (
|
||||||
<h4>
|
<div className="add-panel">
|
||||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
<h4>
|
||||||
</h4>
|
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||||
|
</h4>
|
||||||
<div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
|
<div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>نام تأمینکننده *</label>
|
<label>نام تأمینکننده *</label>
|
||||||
@@ -182,6 +186,7 @@ export function CategoryPage() {
|
|||||||
ذخیره تأمینکننده
|
ذخیره تأمینکننده
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user