37 Commits
Author SHA1 Message Date
fatemeh sane 7bb432e715 feat(dashboard): supporting schema, seed, catalog links, funnel/PM labels and API contract (merge resolution) 2026-10-08 11:18:57 +03:30
fatemeh sane b82c039265 feat(dashboard): reconstruct backend router/service, register route, fix suppliers NameError 2026-10-08 11:18:47 +03:30
fatemeh sane d84637769a fix(dashboard): re-apply filter clear/toggle, 3-level drill-down, Persian formatting + wire /dashboard route 2026-10-08 11:18:47 +03:30
fatemeh sane b0776da061 chore: restore MVP dashboard components from backup 2026-10-08 11:16:15 +03:30
MMD d38cf38bd9 fix(pm): project tiles link by project.id (op_project_id is a dropped legacy column)
Prefers id like ProjectsPage does; the backend serializer returns id (UUID)
and schema_sync removes the legacy op_project_id column at startup.
2026-10-07 15:35:56 +03:30
MMD a1220db844 build(pm): mirror-aware pip/npm registries in PM Dockerfiles (same pattern as api/frontend/bot) 2026-10-07 15:28:43 +03:30
MMD 1190ec42eb feat(pm): add pm-api/pm-frontend services to the ERP stack (serves /pm for the PM panel)
PM joins the ERP traefik (PathPrefix /pm, priority 100), shares the ERP database (additive tables at startup), and reads ERP panels for SSO. PIP_INDEX_URL build arg mirrors the api/bot images.
2026-10-07 15:19:11 +03:30
MMD 2747f9d4bb chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
2026-10-07 15:14:20 +03:30
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30
MMD dcc4d693f5 fix(frontend): declare pathname alongside location in RequireAuth (pm merge semantic conflict) 2026-10-06 15:47:32 +03:30
MMD 64bd292335 merge: pm (unified access matrix, limited procurement flow, platform rebrand)
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/pages/Auth/LoginPage.tsx
#	frontend/src/pages/Wizard/WizardPage.tsx
2026-10-06 15:45:23 +03:30
MMD 98c4b28815 merge: prod-v10 (prod-v8 + prod-v9 consolidation, list-based ChangeInventoryPage + return ledger)
# Conflicts:
#	frontend/src/pages/Inventory/ChangeInventoryPage.tsx
2026-10-06 15:41:28 +03:30
MMD aba9ba6486 merge: prod-v7 (bot/ build-context rename) into the all-branches integration 2026-10-06 15:40:05 +03:30
MMD 965054093e fix(frontend): repair merge-damaged syntax left by the pm/RBAC revert cycle
Stray braces in App.tsx / ChangeInventoryPage.tsx, an orphaned admin gate in ArrivalsPage.tsx, and a literal revert commit subject pasted into LoginPage.tsx — all made the committed tree unbuildable.
2026-10-06 15:39:43 +03:30
whqreza 3931baadd5 feat(inventory): list-based ChangeInventoryPage + mobile-first UX + return ledger controls
- All ChangeInventoryPage sections (withdrawal/damage/waste/loss/transfer/
  stock-count/project-transfer) become list-based like the withdrawal
  section: pick a warehouse, see its stock rows, material name is only an
  optional filter; manual material/brand/unit entry removed everywhere
  except PURCHASE.
- RETURN redesigned: day-range presets (today/yesterday/7d/30d + Jalali
  custom range) with server-side filters, immutable-ledger returnable
  remainder per withdrawal (REF:[tx-id] note tag + FIFO attribution for
  legacy returns, round4 float-safe), destination warehouse selector.
- PROJECT_TRANSFER: row-based list with one destination project per batch;
  sequential submits keep failed rows inline with errors and drop
  succeeded rows; history dashboard untouched.
- PURCHASE: unit locked to catalog (search-only Combobox), wizard-style
  '+ register new material' option with yellow warning, optional Key-Value
  specs editor (replaces mandatory text chips), continuous-entry UX
  (warehouse/date kept, auto-focus, session log, duplicate warning).
- Mobile-first: responsive table/cards core (StockTable), numeric keyboards
  (inputMode+pattern), sticky bottom submit bar with dynamic counter above
  MobileTabBar incl. safe-area inset, horizontally scrollable touch chips.
- Backend fix (prereq of RETURN): GET /inventory/transactions date filters
  crashed with 500 under asyncpg (string vs timestamptz); parse to aware
  UTC datetimes (DB runs Etc/UTC).
- docs/inventory-ledger-gaps.md: ledger gap report (server-side return cap,
  standalone TRANSFER_IN, transfer row fallback, audit coverage, legacy
  REF backfill, UTC day semantics, pagination gaps).
2026-10-03 21:56:46 +03:30
whqreza 1bf55ba8a6 merge: integrate prod-v8 (inter-project transfers + multi-project allocation)
Conflict resolved in ChangeInventoryPage.tsx (canSubmit): kept both the
WITHDRAWAL branch (prod-v9: delivered-to validation) and the
PROJECT_TRANSFER branch (prod-v8: inter-project transfer validation).
All other files auto-merged; both feature sets fully preserved.
2026-10-03 19:43:24 +03:30
whqreza 5458a2f99f merge: integrate prod-v9 (show all warehouse items on selection + track withdrawal recipient) 2026-10-03 19:40:03 +03:30
Milad df3ff4ea14 feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform 2026-10-02 03:47:33 +03:30
MMD 4b84844b54 fix(db): bootstrap the panel-access schema (users.username, user_panels, user_project_members, projects planning columns)
The pm branch's model expects these but nothing created them — login 500s
with UndefinedColumnError users.username on any volume that predates the
RBAC work, including fresh ones (migration.sql never had the column
either). The pm author evidently altered his local DB by hand.

ensure_rbac_schema() now creates all of it idempotently at startup,
alongside the audit/bot_drafts/project_transfers ensures.
2026-10-01 16:45:37 +03:30
MMD ea6a377023 Merge feature/show-material-suppliers into the all-branches integration
Conflict in supplier_materials.py resolved by keeping HEAD's auth
(_procurement_writer) and list route while adopting the branch's UUID
parameter annotations.
2026-10-01 15:44:12 +03:30
MMD 5400588751 Merge pm (panel-based access, supervisor role) onto the restored RBAC base
Reverting 253c922 first made this merge small — only two conflicts:
- MobileTabBar: supervisor null-check combined with v7's /review hiding
- ArrivalsPage: pm's PM+supervisor scoping combined with v8's 3-way fetch
  (transfers) and its transfer-receive helpers, plus pm's canAccess gate
2026-10-01 15:36:57 +03:30
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
2026-10-01 15:24:56 +03:30
MMD a84b33dde3 Merge prod-v8 (multi-project allocation, inter-project transfers) into the all-branches integration
Conflicts (all keep-both):
- init_db.py: v7 added ensure_bot_drafts_table, v8 added
  ensure_project_transfers_table at the same anchor — both functions and
  both startup calls kept
- main.py: same, both ensure calls kept
- ChangeInventoryPage.canSubmit: v9's WITHDRAWAL branch and v8's
  PROJECT_TRANSFER branch — both validation branches kept
2026-10-01 15:05:09 +03:30
MMD fbd34277c0 Merge remote-tracking branch 'origin/prod-v7' into integration-all 2026-10-01 14:43:02 +03:30
fatemeh sane 8c5f4ab770 feat: show material suppliers 2026-10-01 11:16:46 +03:30
Behrad af092fe4c8 fix(dashboard): refine accept modal item allocation layout and live persian number words 2026-09-30 11:38:50 +03:30
Milad e9fef5a104 feat(pm-panel): pm panel level gating — RequirePmPanel/HomeRoute/menu honor panels.pm (full/limited); pm-only users land directly in the PM panel 2026-09-29 13:45:37 +03:30
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30
whqreza d85ede7d11 feat(inventory): display all warehouse items on selection and track withdrawal recipient
- Show all available inventory items immediately when selecting a warehouse in withdrawal form
- Add vertical scrolling container with max height to prevent layout overflow for large item lists
- Add Material column to the inventory rows table for clear item identification
- Add required 'delivered_to' recipient field in withdrawal form with frontend validation
- Track delivered_to in database, models, schemas, and API
- Display recipient badge on withdrawal cards in daily control page
- Group daily control withdrawals by inventory ID and recipient
2026-09-28 22:54:02 +03:30
Milad c7d860f431 feat(inventory): limit supervisor access to own project inventory and remove rectangular change inventory icon 2026-09-27 18:42:05 +03:30
whqreza b2aba2fcfe fix(inventory): enable smart warehouse stock binding and optional brand/specs for inter-project transfer 2026-09-23 23:11:18 +03:30
whqreza e560018273 fix(inventory): envelope transfer responses and add defensive array guards in UI 2026-09-23 22:46:07 +03:30
whqreza 856513b6d7 feat(frontend): integrate inter-project material transfer into ChangeInventoryPage and ArrivalsPage
- Add PROJECT_TRANSFER tab under reductions in ChangeInventoryPage
- Render complete material spec form with destination project selector
- Show matching stock preview in source warehouse
- Add transfer history table with in-transit edit modal and cancellation dialog
- Add incoming transfer cards in ArrivalsPage with 100% full delivery allocation
2026-09-23 22:31:10 +03:30
whqreza b326cf64bf feat(inventory): implement inter-project material transfer backend
- Add ProjectTransfer database model and migration
- Implement project transfer service with immediate stock deduction on creation
- Support 100% full delivery receipt logic across destination warehouses
- Enforce canonical_specs_hash matching for cross-project stock merge
- Add RBAC guards and status lock on received transfers (409 Conflict)
- Register API router for transfers with on-the-way integration
2026-09-23 22:30:31 +03:30
whqreza da883a28c0 feat(dashboard): add multi-project allocation UI in AcceptModal and merge on reopen in EditModal
- Add allocations field to LineItem and propagate in api.quotes.update
- Implement allocation cards in AcceptModal Section 3 with per-row project select and sum validation
- Disable previously selected projects within the same item to prevent duplicates
- Merge split items client-side in EditModal when transitioning status to offered
2026-09-23 22:02:49 +03:30
whqreza b6a1ce2ae7 feat(procurement): support multi-project line item split allocation and merge on reopen
- Add ProjectAllocationIn and allocations field to ItemDecisionIn schema
- Implement line item split allocation across projects in accept_quote
- Replicate all ItemTermsheet options and LineItemSpec records to split rows
- Ensure QuoteProjects and LineItemProjects relations are updated
- Add validation against duplicate project selection and decimal sum mismatches
- Implement _merge_split_line_items_on_reopen to consolidate rows when reverting status to offered
2026-09-23 22:02:24 +03:30
whqreza 64d80e15f8 feat(seed): include ali.mohaghegh admin user in seed plan 2026-09-23 22:02:16 +03:30
198 changed files with 34815 additions and 1684 deletions
+109 -93
View File
@@ -1,3 +1,4 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this
@@ -12,19 +13,22 @@ folder — no external files or sibling folders required.
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
```
Browser ──► Traefik :80 (edge)
│
├── Host(localhost) ──► frontend (nginx SPA :80)
│ │
│ └── /api/* proxy ──► api :8000 (/api/v1)
│
├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console)
│
├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files)
│
├── Host(localhost) ──► frontend (nginx SPA :80)
│ │
│ └── /api/* proxy ──► api :8000 (/api/v1)
│
├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console)
│
├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files)
```
---
@@ -74,6 +78,7 @@ v1.3/
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
```
---
@@ -86,12 +91,12 @@ the form (username / password) is sent to
(password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor.
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only.
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only.
**Developer Mode** (top-left button on the login page) is a 3-step flow for
the admin (super user): ① superuser auth gate (admin credentials only) →
@@ -103,7 +108,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
for production builds).
| What | Where | Credentials |
|---|---|---|
| --- | --- | --- |
| App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
@@ -111,11 +116,11 @@ for production builds).
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
---
@@ -125,13 +130,13 @@ The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS):
| What | Address |
|---|---|
| --- | --- |
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
@@ -140,21 +145,20 @@ The MinIO console is deliberately **loopback-only** on the server
is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel):
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <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**
3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
@@ -168,22 +172,22 @@ tunnel.
Notes from the server setup:
- The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser.
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
- If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default.
* The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser.
* Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
* If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default.
---
## Prerequisites (target machine)
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
- Internet access on first build (image + `npm ci` downloads)
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
* Internet access on first build (image + `npm ci` downloads)
---
@@ -192,6 +196,7 @@ Notes from the server setup:
```powershell
cd <this-folder>
docker compose up -d --build
```
First run downloads base images and builds the frontend (takes a few minutes).
@@ -201,6 +206,7 @@ Wait until all services are up:
```powershell
docker compose ps
```
All should show `Up` (postgres also `(healthy)`).
@@ -210,7 +216,7 @@ All should show `Up` (postgres also `(healthy)`).
## Verify
| What | Where |
|---|---|
| --- | --- |
| Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
@@ -225,23 +231,24 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell
backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py
```
> Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
---
## MinIO (object storage for invoice / proforma files)
| Item | Value |
|---|---|
| --- | --- |
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
| Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell
.\mc.cmd ls --recursive
```
---
@@ -261,10 +269,12 @@ Port: 5433
Database: procurement
Username: postgres
Password: postgres
```
```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement
```
---
@@ -276,26 +286,28 @@ database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up:
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders:
`postgres_data` volume; then run the seeders:
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git:
e.g. `/srv/erp/backups/`), never in git:
```powershell
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```powershell
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```
```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
```
---
## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»).
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote.
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
(`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`).
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars).
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup.
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO.
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`.
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»).
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote.
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
(`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`).
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars).
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup.
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO.
* **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامین‌کننده‌ای برای این متریال ثبت نشده است») if none exist.
* If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`.
```
+79 -11
View File
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
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 (
ForbiddenError,
ServiceUnavailableError,
@@ -106,6 +106,10 @@ class UserContext:
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)
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:
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
if not casdoor_id:
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))
if user is None:
user = User(
casdoor_id=casdoor_id,
username=username or None,
first_name=str(
claims.get("firstName")
or claims.get("firstname")
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
)
db.add(user)
await db.flush()
elif username and not user.username:
user.username = username
await db.flush()
project_ids: list = []
if user.role == "pm":
projects = (
# لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
panels: dict = {}
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(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
select(UserProjectMembership)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).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
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_ids=project_ids,
display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
)
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM user (oldest first)."""
rows = (
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
pm_rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).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:
@@ -217,3 +257,31 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
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
View File
@@ -174,7 +174,7 @@ _V26_DDL = [
""",
"""
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 $$
""",
"""
@@ -247,6 +247,7 @@ _V26_DDL = [
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
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)
""",
"""
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.
"""
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",
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 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("""
@@ -410,6 +415,50 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
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:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """
@@ -466,3 +515,114 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
await conn.execute(text(stmt))
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)")
+6
View File
@@ -28,10 +28,13 @@ from app.db.init_db import (
ensure_attachment_table,
ensure_audit_table,
ensure_bot_drafts_table,
ensure_dashboard_schema,
ensure_drop_category_tables,
ensure_dashboard_tables,
ensure_material_topic_column,
ensure_quote_projects,
ensure_rbac_schema,
ensure_project_transfers_table,
ensure_v26,
)
from app.observability import RequestContextMiddleware, setup_logging
@@ -106,9 +109,12 @@ async def lifespan(_app: FastAPI):
await ensure_material_topic_column(engine)
await ensure_quote_projects(engine)
await ensure_v26(engine)
await ensure_rbac_schema(engine)
await ensure_dashboard_schema(engine)
await ensure_audit_table(engine)
await ensure_drop_category_tables(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
try:
yield
+3
View File
@@ -32,6 +32,8 @@ from app.models.procurement import (
TermsheetScheduleStep,
)
from app.models.project_transfer import ProjectTransfer
__all__ = [
"Base",
"Unit",
@@ -60,4 +62,5 @@ __all__ = [
"ItemDecision",
"ItemReceipt",
"QuoteAttachment",
"ProjectTransfer",
]
+7 -1
View File
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
class Material(Base):
__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"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
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(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
@@ -147,6 +152,7 @@ class Material(Base):
)
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
)
+1
View File
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
# 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))
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(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+54 -1
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum(
"pm", "procurement_manager", "admin",
"pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True,
)
quote_status_enum = Enum(
@@ -58,6 +58,7 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk()
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)
last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text)
@@ -65,6 +66,47 @@ class User(Base):
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):
__tablename__ = "suppliers"
@@ -148,12 +190,23 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
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)
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).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
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):
+87
View File
@@ -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
View File
@@ -4,6 +4,7 @@ from app.routers import (
auth,
brand_materials,
brands,
dashboard,
drafts,
files,
inventory,
@@ -15,6 +16,7 @@ from app.routers import (
material_categories,
materials,
pm,
project_transfers,
projects,
quote_events,
quotes,
@@ -51,6 +53,8 @@ ROUTERS = [
standard_conversions.router,
inventory.router,
pm.router,
project_transfers.router,
dashboard.router,
users.router,
drafts.router,
]
+16 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
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
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:
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
@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))
if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -102,7 +108,10 @@ async def get_attribute_definition(
@router.patch("/{definition_id}")
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)
if ad is None:
@@ -136,7 +145,11 @@ async def update_attribute_definition(
@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)
if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد")
+5 -2
View File
@@ -22,7 +22,7 @@ from app.config import settings
from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict
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
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)
ctx = await context_from_claims(claims, db)
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(
"login_success",
{"username": payload.username, "role": ctx.role},
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
"access_token": access_token,
"token_type": "bearer",
"expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, projects),
"user": await user_to_dict(ctx.user, projects, db),
}
}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material
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
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_procurement_writer()
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)
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:
raise NotFoundError("برند یافت نشد", field="brand_id")
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}")
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)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
@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)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial
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
router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict:
@@ -42,7 +44,11 @@ async def list_brands(
@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()
if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@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)
if brand is None:
raise NotFoundError("برند یافت نشد")
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
@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)
if brand is None:
raise NotFoundError("برند یافت نشد")
+217
View File
@@ -0,0 +1,217 @@
"""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(
category_id: uuid.UUID | None,
material_id: uuid.UUID | None,
brand_id: uuid.UUID | None,
specs_hash: str | None,
) -> dict:
return {
"category_id": category_id,
"material_id": material_id,
"brand_id": brand_id,
"specs_hash": specs_hash,
}
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Cascading options for the global filter bar (Category→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),
category_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""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(category_id, material_id, brand_id, specs_hash),
)
@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),
category_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""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(category_id, material_id, brand_id, specs_hash),
)
@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),
category_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""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(category_id, material_id, brand_id, specs_hash),
)
@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,
)
+4 -3
View File
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"])
# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
# حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
# برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin")
_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
@@ -125,7 +126,7 @@ async def _attachment_or_404(
# 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)):
await _quote_or_404(db, quote_id)
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
# ---------------------------------------------------------------------------
@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)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
+108 -43
View File
@@ -12,6 +12,7 @@ import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
@@ -58,6 +59,7 @@ class Scope:
role: str
project_id: uuid.UUID | None
actor: str | None
project_ids: list[uuid.UUID] | None = None
async def inventory_scope(
@@ -75,25 +77,41 @@ async def inventory_scope(
if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
if user.role == "procurement_manager":
panels = user.panels or {}
if user.role is None and not panels:
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
# ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
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 user.role == "pm":
allowed = list(user.project_ids or [])
# لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
if "inventory" in panels:
if panels["inventory"] == "full":
# کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
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 not in allowed:
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:
raise ValidationError(
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
)
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ForbiddenError("پروژه تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
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
# 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)
def _require_pm(scope: Scope) -> None:
if scope.role != ROLE_PM:
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
def _require_inventory_modifier(scope: Scope) -> None:
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
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,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"note": tx.note,
}
@@ -215,8 +234,16 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db),
):
stmt = select(Warehouse)
if scope.role == ROLE_PM:
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
if scope.role in (ROLE_PM, "supervisor"):
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:
stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name)
@@ -248,9 +275,11 @@ async def create_warehouse(
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id")
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role in (ROLE_PM, "supervisor"):
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:
raise NotFoundError("پروژه یافت نشد", field="project_id")
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),
):
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM:
if scope.project_id != wh.project_id:
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None:
wh.name = payload.name
@@ -292,8 +322,10 @@ async def delete_warehouse(
db: AsyncSession = Depends(get_db),
):
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if scope.role in (ROLE_PM, "supervisor"):
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 cascade — block deletion if any stock history exists.
linked = await db.scalar(
@@ -323,10 +355,22 @@ async def list_inventory(
db: AsyncSession = Depends(get_db),
):
stmt = select(InventoryItem)
if scope.role == ROLE_PM:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id
)
if scope.role in (ROLE_PM, "supervisor"):
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.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:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
@@ -388,8 +432,16 @@ async def list_transactions(
stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role == ROLE_PM:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
if scope.role in (ROLE_PM, "supervisor"):
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:
stmt = stmt.where(Warehouse.project_id == project_id)
if warehouse_id is not None:
@@ -397,9 +449,18 @@ async def list_transactions(
if transaction_type is not None:
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
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:
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")
items, meta = await paginate(stmt, page, page_size, db)
@@ -454,8 +515,10 @@ async def inventory_history(
if item is None:
raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
if scope.role in (ROLE_PM, "supervisor"):
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(
select(InventoryTransaction)
.where(InventoryTransaction.inventory_id == inventory_id)
@@ -474,18 +537,20 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
_require_pm(scope)
_require_inventory_modifier(scope)
# No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure.
with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id)
if wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id != scope.project_id:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if scope.role in (ROLE_PM, "supervisor"):
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None:
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)
await audit_record(
db,
+16 -2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -14,6 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"],
)
_procurement_writer = require_procurement_writer()
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)
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)
decision = await _decision_or_404(db, line_item_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("")
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)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
+18 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
@@ -23,6 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"],
)
_procurement_writer = require_procurement_writer()
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)
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():
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}")
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)
quote = await db.get(Quote, quote_id)
@@ -114,7 +125,11 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}")
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(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
+9 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit
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
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:
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
@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)."""
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
+19 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Material, Unit
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
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:
@@ -50,7 +52,12 @@ async def list_line_items(
@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():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -89,7 +96,11 @@ async def get_line_item(
@router.patch("/{line_item_id}")
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)
quote = await _quote_or_404(db, quote_id)
@@ -131,7 +142,12 @@ async def update_line_item(
@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)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
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
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict:
@@ -46,7 +48,11 @@ async def list_categories(
@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))
if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@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)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
@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)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
+36 -7
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import (
AttributeDefinition,
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str):
@@ -125,7 +127,10 @@ async def list_materials(
@router.post("", status_code=201)
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)
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}")
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)
if material is None:
raise NotFoundError("متریال یافت نشد")
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
@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)
if material is None:
raise NotFoundError("متریال یافت نشد")
@@ -248,7 +262,10 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201)
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():
await _material_or_404(db, material_id)
@@ -287,7 +304,11 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}")
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():
mu = await db.scalar(
@@ -319,7 +340,12 @@ async def update_material_unit(
@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(
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")
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)."""
async with db.begin():
+74 -21
View File
@@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, Unit
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemReceiptAllocation,
LineItem,
LineItemSpec,
Project,
Quote,
Supplier,
@@ -71,16 +72,17 @@ class ArrivalScope:
user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope(
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),
) -> ArrivalScope:
"""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.
Admin / procurement manager → all projects (param = optional filter).
"""
@@ -89,19 +91,19 @@ async def arrivals_scope(
if ctx is not None:
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm":
if ctx.role in ("pm", "supervisor"):
allowed = list(ctx.project_ids or [])
if project_id is not None:
if project_id not in allowed:
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:
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"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM.
@@ -110,8 +112,10 @@ async def arrivals_scope(
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager":
role = "admin"
elif role == "supervisor":
role = "pm"
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.
if project_id is None and role == "pm":
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(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
):
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(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id)
.join(Supplier, Supplier.id == Quote.supplier_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")
)
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
if brand_id is not None:
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)
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_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()}
# 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 = []
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)
out.append({
"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,
"decided_at": to_iso(d.decided_at),
"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,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
@@ -243,8 +285,12 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
if ctx.role == "pm":
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)
quote = await db.get(Quote, li.quote_id)
@@ -451,10 +497,17 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id)
)
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter
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)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows]
+206
View File
@@ -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)}
+56 -14
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select
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.models.procurement import Project, Quote, User
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
router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_procurement_writer()
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 = {
"id": str(project.id),
"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),
"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,
"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)
if pm:
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
@@ -55,7 +62,14 @@ async def list_projects(
):
stmt = select(Project)
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)
items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items)
@@ -64,24 +78,38 @@ async def list_projects(
d = {
"id": str(p.id),
"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),
"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,
"created_at": to_iso(p.created_at),
}
pm = pm_map.get(p.pm_user_id)
if pm:
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)
return {"data": data, "meta": meta}
@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(
name=payload.name,
description=payload.description,
name=payload.name.strip(),
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,
planned_start=payload.start_date,
status="ACTIVE",
)
db.add(project)
await db.flush()
@@ -103,37 +131,51 @@ async def update_project(
request: Request,
ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
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:
project.description = payload.description
project.description = payload.description.strip()
if payload.start_date is not None:
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.
# Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager"):
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
if role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
if payload.pm_user_id is None:
project.pm_user_id = None
else:
pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
if pm_user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="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()
return {"data": await project_to_dict(db, project)}
@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)
if project is None:
raise NotFoundError("پروژه یافت نشد")
+8 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent
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
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_procurement_writer()
@router.get("")
@@ -34,7 +36,12 @@ async def list_events(
@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)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
+65 -10
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select
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.models.catalog import Material
from app.models.procurement import (
@@ -36,12 +37,15 @@ from app.services.expand_service import (
serialize_quote_with_children,
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.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_procurement_writer()
# --- 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)
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)
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}")
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)
return {"data": await serialize_quote(db, quote, set())}
@@ -175,7 +200,11 @@ async def update_quote(
@router.patch("/{quote_id}/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)."""
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")
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)
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")
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)
return {
"data": {
@@ -237,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
@router.patch("/{quote_id}/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)
return {
@@ -259,14 +301,23 @@ async def arrive_items(
@router.patch("/{quote_id}/reject")
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)
return {"data": {"id": str(quote.id), "status": quote.status}}
@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)
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}")
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)
return {"data": result}
+20 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -15,6 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"],
)
_procurement_writer = require_procurement_writer()
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)
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)
step_no = payload.step_no
@@ -84,7 +90,12 @@ async def create_schedule_step(
@router.patch("/{step_id}")
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)
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}")
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)
count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where(
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict:
@@ -41,7 +43,11 @@ async def list_standard_conversions(
@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(
select(StandardConversion.id).where(
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}")
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)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
@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)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
+116 -28
View File
@@ -2,12 +2,15 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — price-comparison data source.
GET /materials/{id}/suppliers — suppliers linked to a material.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial
@@ -18,31 +21,50 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None) -> dict:
def link_to_dict(
link: SupplierMaterial,
material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None,
) -> dict:
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
return {
"id": str(link.id),
"supplier_id": str(link.supplier_id),
"material_id": str(link.material_id),
"is_active": link.is_active,
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
"last_quoted_price": (
float(link.last_quoted_price) if link.last_quoted_price is not None else None
),
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at),
"material": {
"id": str(material.id),
"name": material.name,
"category": {"id": str(category.id), "name": category.name} if category else None,
} if material else None,
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
"material": (
{
"id": str(material.id),
"name": material.name,
"category": (
{"id": str(category.id), "name": category.name} if category else None
),
}
if material
else None
),
"supplier": (
{"id": str(supplier.id), "name": supplier.name} if supplier else None
),
}
# ---------------------------------------------------------------------------
# GET /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials(
supplier_id,
supplier_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
@@ -56,44 +78,110 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at)
)
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
mat_ids = [lnk.material_id for lnk in items]
materials: dict = {}
if mat_ids:
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
materials = {m.id: m for m in result.scalars()}
return {
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
"meta": meta,
}
# ---------------------------------------------------------------------------
# POST /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
supplier_id: UUID,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
return {"data": link_to_dict(link, material, None)}
return {"data": link_to_dict(link, material)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
async def delete_supplier_material(
supplier_id: UUID,
material_id: UUID,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
@router.get("/materials/{material_id}/suppliers")
# ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
material_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Return the suppliers linked to a material, straight from PostgreSQL.
The path parameter is typed as UUID so FastAPI validates and coerces it
before the handler runs; a malformed id is rejected with 422, an unknown
material with 404.
Atomic JOIN — a supplier is returned only through its supplier_materials
link, so the response can never contain a supplier that is not actually
linked to this material:
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
Response envelope (frontend MaterialSupplier binds these fields directly):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
]
}
"""
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
stmt = (
select(SupplierMaterial)
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
.order_by(Supplier.name)
)
items, meta = await paginate(stmt, page, page_size, db)
sup_ids = {l.supplier_id for l in items}
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
suppliers = (await db.execute(stmt)).scalars().all()
return {
"data": [
{
"id": str(supplier.id),
"name": supplier.name,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"location": supplier.location,
}
for supplier in suppliers
]
}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
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
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -71,7 +73,11 @@ async def list_suppliers(
@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)
supplier = Supplier(
name=payload.name,
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@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)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
@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)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
+24 -4
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -80,7 +82,12 @@ async def list_termsheets(
@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():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -110,7 +117,11 @@ async def get_termsheet(
@router.patch("/{termsheet_id}")
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)
if ts.status != "offered":
@@ -127,7 +138,11 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/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():
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -139,7 +154,12 @@ async def update_termsheet_status(
@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)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
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
router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict:
@@ -45,7 +47,11 @@ async def list_units(
@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))
if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@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)
if unit is None:
raise NotFoundError("واحد یافت نشد")
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
@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)
if unit is None:
raise NotFoundError("واحد یافت نشد")
+186 -31
View File
@@ -17,15 +17,21 @@ import uuid
import httpx
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 app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.schemas.procurement import (
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import (
ConflictError,
ForbiddenError,
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات",
"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);
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 []
first = projects[0] if projects else None
return {
out = {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"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],
"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:
"""Admin OR procurement manager (the same person) — organization management."""
if ctx.role not in ("admin", "procurement_manager"):
"""Admin, procurement manager, or project manager — organization management."""
if ctx.role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
if user.role != "pm":
"""Every project assigned to the user (supervisor or PM) (oldest first).
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = (
await db.execute(
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)
)
).scalars().all()
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
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")
@@ -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
project is revoked."""
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]}
@@ -148,20 +212,20 @@ async def list_users(
):
"""Every user (with role + assigned projects) — Organization panel."""
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 = {}
if pm_ids:
if supervisor_ids:
projs = (
await db.execute(
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)
)
).scalars().all()
for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p)
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)},
}
@@ -187,30 +251,30 @@ async def create_user(
db.add(user)
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})
return {"data": user_to_dict(user)}
return {"data": await user_to_dict(user)}
@router.patch("/{user_id}")
async def update_user_role(
user_id,
payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
assign or elevate roles. Revoking (or switching away from «pm») also
unassigns the user from their project."""
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
if payload.role == "admin" and ctx.role != "admin":
raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
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)
user.role = payload.role
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})
projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)}
return {"data": await user_to_dict(user, projects)}
@router.post("/sync")
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
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(
casdoor_id=cid,
username=uname,
first_name=str(
cu.get("firstName")
or cu.get("displayName")
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None,
))
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()
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 = {}
if pm_ids:
if supervisor_ids:
projs = (
await db.execute(
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)
)
).scalars().all()
for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p)
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},
}
@@ -282,17 +357,97 @@ async def assign_user_project(
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
if user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
if payload.project_id is None:
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()
return {"data": user_to_dict(user)}
return {"data": await user_to_dict(user)}
project = await db.get(Project, payload.project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_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()
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)}
+1
View File
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
+26 -2
View File
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
class ProjectCreate(APIModel):
name: str = Field(min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None
start_date: date | None = None
class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | 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).
# Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None
@@ -135,6 +140,11 @@ class AcceptTermsheetIn(APIModel):
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):
line_item_id: UUID
status: Literal["chosen", "rejected"]
@@ -142,6 +152,7 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
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)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel):
@@ -203,13 +214,13 @@ class UserCreate(APIModel):
last_name: str = ""
phone: 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):
"""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):
@@ -341,3 +352,16 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | 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)
+74
View File
@@ -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
+658
View File
@@ -0,0 +1,658 @@
"""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 (category_id / 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 app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, 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, *, category_id=None, material_id=None, brand_id=None, specs_hash=None):
"""«What» filters for a statement that already joins inventory→material."""
if category_id is not None:
stmt = stmt.where(Material.category_id == category_id)
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:
"""Cascading options for the global filter bar.
Categories/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 without a category land in the synthetic «بدون دسته» bucket.
"""
categories = (
await db.execute(
select(MaterialCategory)
.where(MaterialCategory.is_active.is_(True))
.order_by(MaterialCategory.name)
)
).scalars().all()
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())
],
}
buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories}
buckets.setdefault(None, [])
for m in materials:
buckets.setdefault(m.category_id, []).append(material_entry(m))
out = [
{"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])}
for c in categories
]
if buckets.get(None):
out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]})
return {"data": {"categories": out}}
# ---------------------------------------------------------------------------
# Widget 1 — Drill-down matrix (organization → project → warehouse)
# ---------------------------------------------------------------------------
async def inventory_drilldown(
db: AsyncSession,
*,
project_ids,
project_id,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree; each aggregate keyed by unit code
so incompatible units never merge into one number."""
stmt = (
select(
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_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(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.where(InventoryItem.quantity > 0)
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
.order_by(Project.name, Warehouse.name)
)
stmt = _stock_filters(
stmt, category_id=category_id, 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()
units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {}
total_rows = 0
projects_tree: dict[uuid.UUID, dict] = {}
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
q = _qty(qty)
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
total_rows += row_count
proj = projects_tree.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
projects = []
for proj in projects_tree.values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects.append({**proj, "warehouses": warehouses})
projects.sort(key=lambda p: p["name"])
return {
"data": {
"units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"projects": projects,
}
}
# ---------------------------------------------------------------------------
# Widget 2 — Dead stock (dormant capital)
# ---------------------------------------------------------------------------
async def dead_stock(
db: AsyncSession,
*,
project_ids,
project_id,
idle_days: int = 60,
category_id=None,
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, category_id=category_id, 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,
category_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`.
"""
def _with_joins(sel):
return (
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)
)
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 = [Material.category_id == category_id] if category_id is not None else []
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)
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
bucket_rows = (
await db.execute(
_with_joins(
select(day_expr, InventoryTransaction.transaction_type, 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)
)
)
).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,
)
)
)
)
signed_by_day: dict[date_cls, dict[str, float]] = {}
for day, tx_type, signed in bucket_rows:
day_key = day.date() if isinstance(day, datetime) else day
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
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,
})
current += timedelta(days=1)
return {"data": {"opening_balance": opening, "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,
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
@@ -319,6 +320,7 @@ async def apply_transaction(
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
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())
+213
View File
@@ -7,6 +7,7 @@ same transaction.
import asyncio
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy import delete, func, select, update
@@ -18,6 +19,7 @@ from app.models.procurement import (
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
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:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
)
.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!
elif new_status == "accepted": # arrived -> accepted
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"
)
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(
select(ItemTermsheet).where(
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 await db.get(Project, decision.project_id) is None:
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)
else: # rejected
if decision is None:
@@ -903,6 +1100,17 @@ async def quote_stats(
*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 {
"total": sum(counts.values()),
"offered": counts["offered"],
@@ -911,6 +1119,11 @@ async def quote_stats(
"rejected": counts["rejected"],
"expired": counts["expired"],
"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,
},
}
+84
View File
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [
# (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"),
@@ -261,8 +262,11 @@ async def run() -> None:
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
name=name,
topic=cat.name,
category_id=cat.id, # v2.8: dashboard Category→Material cascade
canonical_unit_id=unit_ids[unit_code],
status="active",
)
@@ -440,6 +444,86 @@ async def run() -> None:
))
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()
# summary
+76
View File
@@ -249,6 +249,9 @@ services:
ports:
- "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks:
- traefik_network
- default
@@ -347,6 +350,78 @@ services:
restart: unless-stopped
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:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
@@ -363,3 +438,4 @@ volumes:
postgres_data:
minio_data:
traefik_certs:
pm_files:
+94
View File
@@ -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
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head>
<body>
<div id="root"></div>
+390
View File
@@ -28,6 +28,7 @@
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
@@ -1366,6 +1367,32 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@@ -1714,6 +1741,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": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2003,6 +2042,69 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"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": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -2033,6 +2135,12 @@
"@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": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -2127,6 +2235,133 @@
"devOptional": true,
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2155,6 +2390,24 @@
"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": {
"version": "6.5.0",
"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==",
"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": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2645,6 +2917,36 @@
"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": {
"version": "2.7.2",
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@@ -2884,6 +3237,12 @@
"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": {
"version": "0.2.17",
"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": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "دفترچه تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "مدیریت پروژه و تأمین",
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl",
"lang": "fa",
"start_url": "/",
+294 -9
View File
@@ -38,11 +38,18 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
);
const OrganizationPage = lazy(() =>
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
);
const AdminPanelPage = lazy(() =>
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() {
return (
@@ -56,6 +63,7 @@ function Loading() {
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const location = useLocation();
const pathname = location.pathname;
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search);
@@ -68,14 +76,249 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
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}</>;
}
// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
function HomeRoute() {
const user = useAuthStore((s) => s.user);
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />;
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");
// ۱. اگر پنل تدارکات دارد:
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() {
@@ -97,10 +340,13 @@ export default function App() {
path="/wizard"
element={
<RequireAuth>
<WizardPage />
<RequireProcurementWrite>
<WizardPage />
</RequireProcurementWrite>
</RequireAuth>
}
/>
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route
path="/suppliers"
element={
@@ -144,6 +390,17 @@ export default function App() {
</RequireAuth>
}
/>
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route
path="/units"
element={
@@ -177,7 +434,9 @@ export default function App() {
element={
<RequireAuth>
<RequirePmProjects>
<ChangeInventoryPage />
<RequireInventoryChange>
<ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects>
</RequireAuth>
}
@@ -197,18 +456,24 @@ export default function App() {
element={
<RequireAuth>
<RequirePmProjects>
<WarehousesPage />
<RequireWarehouseManage>
<WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects>
</RequireAuth>
}
/>
<Route
path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={
<RequireAuth>
<RequirePmProjects>
<RequireOrganization>
<OrganizationPage />
</RequirePmProjects>
</RequireOrganization>
</RequireAuth>
}
/>
@@ -220,6 +485,26 @@ export default function App() {
</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 />} />
</Routes>
</Suspense>
+67 -31
View File
@@ -1,48 +1,84 @@
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
// ۱. مدیریت انبار (/inventory)
// ۲. مدیریت تدارکات (/)
// ۳. مدیریت پروژه (/panel/pm)
// ۴. سازمان‌دهی (/organization)
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
}: {
activeModule: "warehouse" | "procurement";
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
activeModule: "warehouse" | "procurement" | "organization";
canSwitchToProcurement?: boolean;
}) {
const logout = useLogout();
const { pathname } = useLocation();
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 isPm = user?.role === "pm";
const pmProjects = isPm ? user?.projects ?? [] : [];
const projectChildren: MenuItem[] | undefined =
activeModule === "warehouse" && isPm
? pmProjects.map((p) => ({
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 (hasInventory) {
items.push({
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
});
}
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({ label: "خروج از حساب", onClick: logout });
return <PanelMenu title="پنل‌ها" items={items} />;
+58 -6
View File
@@ -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 { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
@@ -17,6 +18,7 @@ export interface ComboboxProps {
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از +
placeholder?: string;
className?: string;
@@ -25,6 +27,11 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی
// (و فیلتر وابسته به آن) به‌تنهایی پاک شود.
onClear?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
@@ -44,6 +51,7 @@ export function Combobox({
onChange,
items,
allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید",
placeholder,
className,
@@ -52,11 +60,17 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
onClear,
inputRef,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1);
// کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز
// می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک
// دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته.
const justOpenedByFocus = useRef(false);
useEffect(() => {
setText(value ?? "");
@@ -77,6 +91,8 @@ export function Combobox({
const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0;
const listCount = filtered.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
@@ -125,8 +141,10 @@ export function Combobox({
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
ref={inputRef}
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",
showClear && "pe-8",
inputClassName,
)}
value={text}
@@ -140,12 +158,40 @@ export function Combobox({
onInputChange?.(e.target.value);
}}
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}
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 && (
<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 && (
@@ -182,9 +228,15 @@ export function Combobox({
onMouseEnter={() => setHighlight(filtered.length)}
>
<span className="font-bold">+</span>
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
{addPrefix ? (
<span>
{addPrefix} «<b>{text.trim()}</b>»
</span>
) : (
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
)}
</button>
)}
</div>
+17
View File
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
export interface MenuItem {
label?: string;
to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean;
divider?: boolean;
onClick?: () => void;
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</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 ? (
<Link
key={i}
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout();
const isPm = user?.role === "pm";
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
// پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => {
if (!user || user.role !== "pm") {
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
if (!user || !scoped) {
setState("ok");
return;
}
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, isDev]);
}, [user?.id, user?.role, user?.panels, isDev]);
if (state === "loading") {
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,118 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter } from "lucide-react";
import { Combobox } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
const categories = filterQuery.data?.categories ?? [];
// متریال‌های سطح فعلی: اگر دسته انتخاب شده باشد فقط متریال‌های همان دسته
const materialOptions = useMemo(() => {
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
// حذف متریال تکراری وقتی «همه‌ی دسته‌ها» فعال است
const seen = new Set<string>();
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
}, [categories, state.categoryId]);
const selectedMaterial = useMemo(
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
[materialOptions, state.materialId],
);
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
const specLabel = (specs: Record<string, string> | null) => {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
};
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">دسته‌بندی ← متریال ← برند ← مشخصات فنی</p>
</div>
{activeCount > 0 && (
<button
type="button"
onClick={state.reset}
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
>
حذف فیلترها ({faNum(activeCount)})
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{/* ۱ — دسته‌بندی */}
<Combobox
value={state.categoryLabel ?? ""}
placeholder="همه‌ی دسته‌ها"
allowAdd={false}
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
onClear={() => state.setCategory(null, null)}
onChange={(item) => state.setCategory(item.id, item.label)}
/>
{/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder="همه‌ی متریال‌ها"
allowAdd={false}
disabled={materialOptions.length === 0}
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
onClear={() => state.setMaterial(null, null)}
onChange={(item) => state.setMaterial(item.id, item.label)}
/>
{/* ۳ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی برندها" : "ابتدا متریال را انتخاب کنید"}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
onClear={() => state.setBrand(null, null)}
onChange={(item) => state.setBrand(item.id, item.label)}
/>
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی مشخصات" : "ابتدا متریال را انتخاب کنید"}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
onClear={() => state.setSpecs(null, null)}
onChange={(item) => state.setSpecs(item.id, item.label)}
/>
</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,275 @@
// ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI)
// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان
// سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه
// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
// همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند —
// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی
// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد.
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChevronLeft, MousePointerClick } from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { fmtNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
UnitPicker,
faAxisTick,
} from "./chartShared";
type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها
interface Slice {
id: string;
name: string;
value: number;
rows: number;
}
const LEVEL_TITLES: Record<Level, string> = {
0: "کل سازمان",
1: "پروژه‌ها",
2: "انبارها",
};
// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
export function DrilldownMatrix() {
const query = useDrilldown();
const [unit, setUnit] = useState<string | null>(null);
const [level, setLevel] = useState<Level>(0);
const [projectId, setProjectId] = useState<string | null>(null);
const data = query.data;
// واحد فعال: انتخاب کاربر یا بزرگ‌ترین مجموع (پایدار بین رندرها)
const activeUnit = useMemo(() => {
if (unit && data?.units?.[unit]) return unit;
const entries = Object.entries(data?.total?.qty_by_unit ?? {});
if (!entries.length) return null;
return entries.sort((a, b) => b[1] - a[1])[0][0];
}, [unit, data]);
const selectedProject = useMemo(
() => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null),
[data, projectId],
);
// کلمپِ خودکار: اگر پروژه‌ی انتخابی با فیلترهای جدید از داده خارج شد،
// به سطح پروژه‌ها برگرد (بدون هیچ مقدار ثابت).
useEffect(() => {
if (level === 2 && projectId && data && !selectedProject) {
setProjectId(null);
setLevel(1);
}
if (data && data.projects.length === 0 && level !== 0) {
setProjectId(null);
setLevel(0);
}
}, [data, level, projectId, selectedProject]);
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
// برش‌های سطح فعال — کاملاً از پاسخ API
const slices: Slice[] = useMemo(() => {
if (!data) return [];
if (level === 0) {
return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }];
}
if (level === 1) {
return data.projects.map((p) => ({
id: p.id,
name: p.name,
value: pick(p.qty_by_unit),
rows: p.rows,
}));
}
return (selectedProject?.warehouses ?? []).map((w) => ({
id: w.id,
name: w.name,
value: pick(w.qty_by_unit),
rows: w.rows,
}));
}, [data, level, selectedProject, activeUnit]);
const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
const levelTotal = slices.reduce((s, x) => s + x.value, 0);
const total = data ? pick(data.total.qty_by_unit) : 0;
const handleBarClick = (slice: Slice) => {
if (level === 0 && data && data.projects.length > 0) {
setLevel(1);
} else if (level === 1) {
setProjectId(slice.id);
setLevel(2);
}
// سطح ۳ برگ است — کلیک دیگری لازم نیست
};
const subText =
level === 0
? "تجمیع موجودی همه‌ی انبارهای سازمان برای فیلترهای فعال — برای مشاهده‌ی سهم پروژه‌ها کلیک کنید"
: level === 1
? "سهم هر پروژه از موجودی — برای مشاهده‌ی انبارهای هر پروژه روی ستون آن کلیک کنید"
: `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`;
return (
<ChartCard
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
sub={subText}
actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
>
{/* مسیر Drill-Down (RTL) */}
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(0);
}}
className={
level === 0
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
}
>
{LEVEL_TITLES[0]}
</button>
{level >= 1 && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(1);
}}
className={
level === 1
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
}
>
{LEVEL_TITLES[1]}
</button>
</>
)}
{level >= 2 && selectedProject && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
{selectedProject.name}
</span>
<span className="text-muted">← {LEVEL_TITLES[2]}</span>
</>
)}
<span className="ms-auto text-[11px] tabular-nums text-muted">
جمع سطح فعال: {fmtNum(levelTotal)} {unitLabel}
{level > 0 && levelTotal !== total && (
<span className="ms-1">از {fmtNum(total)} {unitLabel} کل سازمان</span>
)}
</span>
</nav>
{query.isLoading ? (
<ChartLoading />
) : !slices.length || (levelTotal === 0 && level === 0) ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : (
<>
<div dir="ltr" className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={slices} margin={{ top: 26, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={44}
angle={slices.length > 4 ? -18 : 0}
textAnchor={slices.length > 4 ? "end" : "middle"}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={
<ChartTooltip
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
/>
}
/>
<Bar
dataKey="value"
name="موجودی"
radius={[6, 6, 0, 0]}
maxBarSize={level === 0 ? 110 : 64}
isAnimationActive={false}
onClick={(entry: unknown) => {
const payload = (entry as { payload?: Slice })?.payload;
if (payload) handleBarClick(payload);
}}
className={level < 2 ? "cursor-pointer" : ""}
>
<LabelList
dataKey="value"
position="top"
fill="#0f172a"
fontSize={11}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? fmtNum(n) : "";
}}
/>
{slices.map((s, i) => (
<Cell
key={s.id}
fill={
level === 0
? CHART_COLORS.accent
: level === 1
? "#818cf8"
: WAREHOUSE_PALETTE[i % WAREHOUSE_PALETTE.length]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{level < 2 && (
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
<MousePointerClick className="h-3.5 w-3.5" />
{level === 0 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"}
</p>
)}
</>
)}
</ChartCard>
);
}
@@ -0,0 +1,130 @@
// ============================================================
// ویجت ۳ — سری زمانی جریان روزانه
// ستون‌های روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایه‌ی
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
// به‌علاوه‌ی خط «مانده» (بالانس تجمعی). تاریخ‌ها شمسی، اعداد فارسی.
// ============================================================
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useDailyFlow } from "../../hooks/useDashboardData";
import { isoToJalaliStr } from "../../lib/jalali";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
interface Row {
date: string;
ورود: number;
برگشت: number;
"خروج سایت": number;
"ضایعات و اتلاف": number;
مانده: number;
_total: number;
}
export function FlowChart() {
const query = useDailyFlow();
const rows: Row[] = useMemo(
() =>
(query.data?.days ?? []).map((d) => ({
date: d.date,
ورود: d.inbound,
برگشت: d.returns,
"خروج سایت": Math.max(0, d.outbound - d.waste),
"ضایعات و اتلاف": d.waste,
مانده: d.balance,
_total: d.inbound + d.outbound + d.returns,
})),
[query.data],
);
const hasAnyData = rows.some((r) => r._total > 0);
return (
<ChartCard
title="جریان روزانه موجودی"
sub="ورود، خروج (با لایه‌ی قرمز ضایعات)، برگشت از سایت و مانده‌ی تجمعی — ۳۰ روز اخیر"
>
{query.isLoading ? (
<ChartLoading />
) : !hasAnyData ? (
<ChartEmpty message="در این بازه جابه‌جایی موجودی ثبت نشده است" />
) : (
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 12, right: 8, 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
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={
<ChartTooltip
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
/>
}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)}
/>
{/* ستون‌های کنار هم: ورود و برگشت */}
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
{/* ستون خروج: دو لایه‌ی سته — بخش قرمز ضایعات داخل ستون خروج */}
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Line
type="monotone"
dataKey="مانده"
stroke={CHART_COLORS.balance}
strokeWidth={1.5}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</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>
);
}
+22 -1
View File
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronRight, ClipboardList } from "lucide-react";
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 {
title: string;
@@ -18,6 +35,10 @@ interface HeaderProps {
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const userSub = userSubtitle(user);
const displaySub = sub !== undefined ? sub : userSub;
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛
// اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به 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="min-w-0">
<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 className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
@@ -8,7 +8,6 @@ import {
PackageSearch,
Scale,
Truck,
UsersRound,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
// تب‌های پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
];
function TabBar({ tabs }: { tabs: TabDef[] }) {
@@ -81,17 +80,28 @@ export function MobileTabBar() {
const { pathname } = useLocation();
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 isSupervisor = role === "supervisor";
const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) {
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;
}
// سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد
if (isSupervisor) return null;
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage =
pathname === "/" ||
pathname === "/dashboard" ||
pathname === "/projects" ||
pathname === "/units" ||
pathname.startsWith("/suppliers");
+1 -1
View File
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects";
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
+90
View File
@@ -0,0 +1,90 @@
// ============================================================
// هوک‌های 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) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
});
}
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازه‌سازی می‌شود
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,
});
}
+12
View File
@@ -21,3 +21,15 @@ export function useAddProject() {
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(),
});
}
+235 -26
View File
@@ -19,19 +19,39 @@ import { useAuthStore } from "../store/auth";
import type {
Attachment,
ConfirmedReceipt,
DashboardFilterOptions,
DailyControlData,
DeadStockItem,
DrilldownData,
FlowDay,
InTransitRow,
InventoryRow,
InventoryTransaction,
Material,
PendingArrival,
Project,
Quote,
QuoteStats,
StandardConversion,
Store,
Supplier,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferReceiveIn,
ProjectTransferUpdateIn,
TransactionType,
Warehouse,
WorkloadProject,
} from "./types";
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = {
category_id?: string | null;
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 PAGE_SIZE = 100;
@@ -87,7 +107,7 @@ function handle401(path: string, res: Response): void {
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null {
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 {
@@ -239,20 +259,36 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
async function getData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path);
return res.data;
const res = await request<any>(path);
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> {
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
return res.data;
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
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> {
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;
}
async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data;
const res = await request<any>(path, { method: "DELETE" });
if (res && typeof res === "object" && "data" in res) {
return res.data as T;
}
return res as T;
}
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
@@ -323,6 +359,12 @@ interface RawSupplier {
interface RawProject {
id: 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 {
attribute_key: string;
@@ -458,6 +500,39 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
}
// تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers
// Raw shape mirrors the backend dict exactly: clean supplier objects produced
// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name
// always comes from the suppliers table, never from the material row.
interface RawMaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
/** Clean UI-facing type for one supplier linked to a material. */
export interface MaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
return {
id: raw.id,
// Bound straight from the supplier object in the API response — there is
// no fallback, so a supplier row can never render the material's name.
name: raw.name,
phone: raw.phone,
contact_person: raw.contact_person,
location: raw.location,
};
}
// فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment {
id: string;
@@ -927,13 +1002,28 @@ export const api = {
if (becomingAccepted) {
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
// پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id)
// پروژه‌ی واحدِ انتخاب‌شده یا تخصیص‌های چندپروژه‌ای برای هر قلم
const targetProjByLine = new Map<string, string>();
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
for (const li of patch.line_items || []) {
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);
if (li.allocations && li.allocations.length > 0) {
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
for (const a of li.allocations) {
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 +1043,13 @@ export const api = {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0;
const chosenProjId = targetProjByLine.get(li.id);
const lineAllocs = allocationsByLine.get(li.id);
return {
line_item_id: li.id,
status: chosen ? "chosen" : "rejected",
...(chosen ? { accepted_termsheet_index: idx } : {}),
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
};
}),
@@ -1193,16 +1285,51 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id });
},
// فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers)
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
// 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: {
list: async (): Promise<Project[]> => {
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> => {
const raw = await postData<RawProject>("/projects", { name: p.name });
return { id: raw.id, name: raw.name };
const raw = await postData<RawProject>("/projects", {
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 +1719,16 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
export interface UserRow {
id: string;
casdoor_id: string;
username?: string | null;
first_name: string;
last_name: string;
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...}
panels?: Record<string, string>;
// عضویت M:N پروژه‌ای — محدوده داده سطح limited
member_project_ids?: string[];
project_id: string | null;
project_name: string | null;
// 1:N — every project this PM manages (oldest first).
@@ -1620,10 +1752,11 @@ export const apiV26 = {
user: {
id: string;
casdoor_id: string;
username?: string;
first_name: string;
last_name: string;
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null;
project_id: string | null;
project_name: string | null;
@@ -1637,6 +1770,7 @@ export const apiV26 = {
expires_in: res.data.expires_in,
user: {
...u,
username: u.username || u.casdoor_id,
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
},
};
@@ -1656,14 +1790,15 @@ export const apiV26 = {
users: {
me: async (): Promise<import("../store/auth").AuthUser> => {
const data = await getData<{
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[];
}>("/users/me");
return {
id: data.id,
casdoor_id: data.casdoor_id,
username: data.username || data.casdoor_id,
first_name: data.first_name,
last_name: data.last_name,
phone: data.phone,
@@ -1687,8 +1822,13 @@ export const apiV26 = {
first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): 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 }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
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> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
@@ -1736,16 +1876,23 @@ export const apiV26 = {
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
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 qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
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: {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | 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[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
@@ -1756,8 +1903,12 @@ export const apiV26 = {
pm: {
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => {
const qs = new URLSearchParams(
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
).toString();
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
},
confirmReceipt: async (body: {
item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[];
@@ -1768,6 +1919,27 @@ export const apiV26 = {
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<{ opening_balance: number; days: FlowDay[] }> =>
getData<{ opening_balance: number; days: FlowDay[] }>(`/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: {
@@ -1796,4 +1968,41 @@ export const apiV26 = {
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),
},
};
+188
View File
@@ -31,6 +31,12 @@ export interface Material {
export interface Project {
id: 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)
target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id)
target_project_id?: string | null;
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژه‌ای در پذیرش
}
// پیشنهاد خرید (اصلی‌ترین entity)
@@ -163,6 +170,7 @@ export interface InventoryTransaction {
physical_qty?: number | null;
transfer_pair_id?: string | null;
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
note?: string | null;
material?: { id: string; name: string } | null;
@@ -187,6 +195,12 @@ export interface PendingArrival {
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 {
receipt_id: string;
quote_id: string;
@@ -199,3 +213,177 @@ export interface ConfirmedReceipt {
quality_note: string | null;
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 FilterCategoryOption {
id: string | null; // null = «بدون دسته»
name: string;
slug: string | null;
materials: FilterMaterialOption[];
}
export interface DashboardFilterOptions {
categories: FilterCategoryOption[];
}
// — ماتریس 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 DrilldownData {
units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number };
projects: DrilldownProject[];
}
// — جدول موجودی راکد —
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 interface FlowDay {
date: string; // ISO (YYYY-MM-DD)
inbound: number;
outbound: number;
waste: number;
returns: number;
balance: number;
}
// — کنترل روزانه —
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;
}
+47 -12
View File
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
supervisor: "سرپرست پروژه",
};
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از 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) {
return (
<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">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت مدیر تدارکات</th>
<th className="py-2">مدیریت نقش‌ها</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold">
@@ -147,17 +169,30 @@ export function AdminPanel() {
</span>
</td>
<td className="py-2">
<button
disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isPMgr
? "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 ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button>
<div className="flex flex-wrap items-center gap-1.5">
<button
disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isPMgr
? "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 ? "…" : 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>
</tr>
);
+38 -5
View File
@@ -79,6 +79,13 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
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);
setUser(user);
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
@@ -88,8 +95,23 @@ export function LoginPage() {
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} 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) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
@@ -280,10 +302,21 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} />
</span>
<div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
<p className="mt-1 text-[12.5px] leading-6 text-muted">
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
</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>
+434 -62
View File
@@ -23,12 +23,134 @@ import {
import { MilestoneRow } from "../../components/MilestoneRow";
import { FileList } from "../../components/FileList";
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 {
domId: string;
isNew: boolean;
milestones: Milestone[];
label: string | null; // null = پیش‌فرض «گزینه N»
label: string | null;
}
export interface AcceptModalProps {
@@ -51,6 +173,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = 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 [ready, setReady] = useState(false);
@@ -67,17 +192,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(rem);
setAssignments({});
const projSel: Record<string, string> = {};
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
q.line_items.forEach((li) => {
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
: names.length
? names[0]
: q.projects && q.projects.length
? q.projects[0]
: 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);
setItemAllocations(allocs);
setReady(true);
}, [q]);
@@ -137,6 +280,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
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 = () => {
if (!q) return;
@@ -162,6 +343,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true;
});
const active = activeItems(next);
if (!active.length) {
toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
@@ -206,13 +388,60 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
for (const li of active) {
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) => {
if (li.rejected) return;
const name = itemProjectsSel[li.id];
if (name === undefined) return;
li.target_project = name ? { id: "", name } : null;
const allocs = itemAllocations[li.id];
if (allocs && allocs.length) {
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();
@@ -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">
<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">
<DialogHeader className="p-0 text-right">
<DialogTitle className="text-right text-lg font-bold text-slate-800">
@@ -343,53 +572,196 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه */}
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
<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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
return (
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)}
</span>
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit}
</span>
{projectOptions.length === 0 ? (
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید.
</div>
) : (
<div className="flex flex-col gap-4">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
const targetQty = Number(li.quantity ?? 0);
const allocs = itemAllocations[li.id] || [];
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>
{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"
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>
);
})}
</div>
)}
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
@@ -441,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div>
{/* فوتر ثابت */}
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف
</Button>
<Button onClick={confirm} className="w-full sm:w-auto">
ثبت معامله بسته‌شده
</Button>
</DialogFooter>
</div>
{/* فوتر ثابت */}
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف
</Button>
<Button onClick={confirm} className="w-full sm:w-auto">
ثبت معامله بسته‌شده
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
+34 -16
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react";
import {
useMaterials,
useProjects,
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal";
import { useAuthStore } from "../../store/auth";
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() {
useStoreData();
const user = useAuthStore((s) => s.user);
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
@@ -85,11 +89,20 @@ export function DashboardPage() {
return (
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="دفتر پیشنهادهای خرید"
title="مدیریت تدارکات"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
<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">
<Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها
@@ -114,13 +127,15 @@ export function DashboardPage() {
</Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
<Link
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>
</Link>
{canWrite && (
<Link
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>
</Link>
)}
</div>
}
/>
@@ -148,17 +163,20 @@ export function DashboardPage() {
onToggleExpand={toggleExpand}
onAccept={setAcceptId}
onEdit={setEditId}
canWrite={canWrite}
/>
</PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
<Link
to="/wizard"
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>
{canWrite && (
<Link
to="/wizard"
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>
)}
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
@@ -438,7 +438,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
li.accepted_ts_id = null;
li.target_project = 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") {
next.arrived_date = null;
next.line_items.forEach((li) => {
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
onToggleExpand: (id: string) => void;
onAccept: (id: string) => void;
onEdit: (id: string) => void;
canWrite?: boolean;
}
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
@@ -20,6 +21,7 @@ export function LedgerTable({
onToggleExpand,
onAccept,
onEdit,
canWrite = true,
}: LedgerTableProps) {
if (!quotes.length) {
return (
@@ -40,6 +42,7 @@ export function LedgerTable({
onToggleExpand={() => onToggleExpand(q.id)}
onAccept={() => onAccept(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]">
@@ -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>
);
}
+20 -15
View File
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
onToggleExpand: () => void;
onAccept: () => void;
onEdit: () => void;
canWrite?: boolean;
}
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 expired = isExpired(q);
const total = quoteTotal(q);
@@ -46,7 +47,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
acceptedCell = canWrite ? (
<button
onClick={(e) => {
e.stopPropagation();
@@ -57,6 +58,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Check className="h-3.5 w-3.5" />
<span>پذیرش</span>
</button>
) : (
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
);
} else if (q.accepted_date) {
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
type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span>
</button>
{canWrite && (
<button
type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span>
</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>
);
}
+229 -19
View File
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api";
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";
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
@@ -23,36 +23,45 @@ export function ArrivalsPage() {
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canAccess = isAdmin || isPM || isSupervisor;
const [items, setItems] = useState<PendingArrival[]>([]);
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [loading, setLoading] = useState(true);
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 [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
const load = useCallback(async () => {
try {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
const [arr, whs, incoming] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM / سرپرست را بررسی می‌کند
apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject),
apiV26.projectTransfers.onTheWay(activeProject),
]);
setItems(arr);
setWarehouses(whs);
setItems(Array.isArray(arr) ? arr : []);
setWarehouses(Array.isArray(whs) ? whs : []);
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
setItems([]);
setIncomingTransfers([]);
} finally {
setLoading(false);
}
}, [isPM, user?.project_id]);
}, [isPM, isSupervisor, user?.project_id]);
useEffect(() => {
if (!isAdmin && !isPM) return;
if (!canAccess) return;
setLoading(true);
void load();
}, [isAdmin, isPM, load]);
}, [canAccess, load]);
// سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص)
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 (
<div className="min-h-screen">
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
</PageLayout>
</div>
);
@@ -233,19 +290,171 @@ export function ArrivalsPage() {
/>
<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">
🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان
وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از
مانده‌ی سفارش بیشتر نشود.
🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
</div>
{loading ? (
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
) : !orders.length ? (
) : !(orders || []).length && !(incomingTransfers || []).length ? (
<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 open = expanded.has(quoteId);
const hasSubmittable = orderHasSubmittable(quoteId);
@@ -401,7 +610,8 @@ export function ArrivalsPage() {
)}
</div>
);
})
})}
</>
)}
</PageLayout>
</div>
File diff suppressed because it is too large Load Diff
@@ -41,10 +41,12 @@ export function DailyControlPage() {
const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>();
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
const projWhIds = projFilter
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
: null;
data.transactions.forEach((t) => {
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
const k = gregKey(new Date(t.transaction_date));
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()));
return map;
}, [data.transactions, data.warehouses, projFilter]);
}, [data.transactions, data.warehouses, projFilter, isAdmin]);
const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
@@ -129,9 +131,10 @@ export function DailyControlPage() {
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
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) {
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 {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
@@ -172,11 +175,15 @@ export function DailyControlPage() {
))}
</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>
<Link
@@ -324,6 +331,12 @@ export function DailyControlPage() {
whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")}
</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 className="text-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
+16 -13
View File
@@ -51,8 +51,11 @@ export function InventoryPage() {
const location = useLocation();
const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || 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 [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -67,10 +70,10 @@ export function InventoryPage() {
const [expBrand, setExpBrand] = useState<string | null>(null);
const visibleWarehouses = useMemo(() => {
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isAdmin) return warehouses;
if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isManager) return warehouses;
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]);
@@ -133,12 +136,12 @@ export function InventoryPage() {
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) {
if (!user) {
return (
<div className="min-h-screen">
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
<PageLayout>
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
<EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
</PageLayout>
</div>
);
@@ -150,10 +153,10 @@ export function InventoryPage() {
title="سامانه مدیریت انبار"
sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
menu={<AppSwitcherMenu activeModule="warehouse" />}
actions={
<div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
<InventoryTopNav pathname={location.pathname} />
</div>
}
/>
@@ -246,8 +249,8 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && (
<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 className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
<div className="flex flex-wrap gap-1.5">
@@ -415,7 +418,7 @@ export function InventoryPage() {
</PageLayout>
{/* دکمه شناور تغییر موجودی */}
{isPM && (
{canModify && (
<Link
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"
@@ -1,26 +1,27 @@
import { Link } from "react-router-dom";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها
export function InventoryTopNav({
pathname,
variant = "pm",
}: {
pathname: string;
variant?: "pm" | "admin";
}) {
const items =
variant === "admin"
? [
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
{ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") },
]
: [
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
];
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin";
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
const isSupervisor = user?.role === "supervisor";
const canViewTabs = isManager || isSupervisor;
const items: { label: string; to: string; active: boolean }[] = [];
// برای تغییر موجودی از همان دکمه شناور دایره‌ای آبی (+) استفاده می‌شود و دکمه مستطیلی در بالای صفحه حذف شد
if (canViewTabs) {
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/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
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>
);
}
+26 -23
View File
@@ -9,52 +9,42 @@ import { useInventoryData, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد)
// انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
export function WarehousesPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازمان‌دهی»
if (isAdmin) {
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="rounded-xl border border-line bg-panel p-8 text-center">
<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>
</div>
</PageLayout>
</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 name = newName.trim();
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("پروژه انبار را انتخاب کنید");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
@@ -99,13 +89,26 @@ export function WarehousesPage() {
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{isPM && (
{canManage && (
<div className="rounded-xl border border-line bg-panel p-3">
<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
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام انبار (مثلاً انبار موقت)"
placeholder="نام انبار (مثلاً انبار مرکزی)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
@@ -148,7 +151,7 @@ export function WarehousesPage() {
>
📊 مشاهده داشبورد انبار
</button>
{isPM && (
{canManage && (
<>
<button
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
@@ -162,7 +165,7 @@ export function WarehousesPage() {
</>
)}
</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()}>
<input
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);
}
+21 -29
View File
@@ -10,20 +10,22 @@ export function useLogout() {
}
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
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 "در حال بارگذاری…";
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 proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژه‌ها";
return `${name} | ${role} – ${proj}`;
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
}
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
return items;
}
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمه‌ی خروجِ تکی هم حذف شده).
// منوی همبرگری سرپرست پروژه در پنل انبار
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
const items: MenuItem[] = [];
if (onLogout) {
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
return items;
}
// آیتم‌های منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
//
// دسترسی‌ها (v2.6):
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازمان‌دهی —
// بدون «تغییر موجودی» و بدون «انبارها».
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
// بدون «سازمان‌دهی».
// آیتم‌های بخش‌های داخلی انبار
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
const isAdmin = role === "admin" || role === "procurement_manager";
const isPM = role === "pm";
const isAdmin = role === "admin";
const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
const isSupervisor = role === "supervisor" || isAdmin;
const items: MenuItem[] = [
{ 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/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/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: "/" });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
if (onLogout) {
items.push({ divider: true });
@@ -26,8 +26,8 @@ export interface InventoryData {
export function useInventoryData(): InventoryData {
const user = useAuthStore((s) => s.user);
const isPM = user?.role === "pm";
const projectId = isPM ? user.project_id || undefined : undefined;
const isSupervisor = user?.role === "supervisor";
const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
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>
);
}
+167 -62
View File
@@ -1,11 +1,11 @@
import { useState } from "react";
import { Link } from "react-router-dom";
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 { nextId } from "../../lib/api";
import {
useAddProject,
useDeleteProject,
useMaterials,
useProjects,
useQuotes,
@@ -25,6 +25,7 @@ import { faNum, fmtDate } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
function projectQuotes(quotes: Quote[], name: string): Quote[] {
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 {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
}
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project);
}
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name);
@@ -73,6 +65,97 @@ function daysSince(iso: string | null): number | null {
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() {
useStoreData();
@@ -82,54 +165,53 @@ export function ProjectsPage() {
const materials = useMaterials();
const { standardConversions } = useUnits();
const addProject = useAddProject();
const deleteProject = useDeleteProject();
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 [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const [showAddModal, setShowAddModal] = useState(false);
const addProj = () => {
const name = newName.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p.name) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
const handleAddProject = async (data: Omit<Project, "id">) => {
const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
try {
await addProject.mutateAsync({ id: "", ...data });
setShowAddModal(false);
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 (
<div className="min-h-screen">
<Header title="پروژه‌ها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
<Header title="پروژه‌ها" sub="مدیریت پروژه‌های مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout className="flex flex-col gap-4">
<div className="add-panel">
<h4>+ افزودن پروژه‌ی جدید</h4>
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
<div className="field">
<label>نام پروژه *</label>
<input
type="text"
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 className="add-panel flex items-center justify-between">
<h4>پروژه‌های سامانه</h4>
{canManage && (
<Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
<Plus size={15} />
افزودن پروژه
</Button>
)}
</div>
{!projects.length && !quotes.length ? (
@@ -141,16 +223,37 @@ export function ProjectsPage() {
const open = openProject === p.name;
return (
<div key={p.id} className="flex flex-col gap-1.5">
<button
type="button"
className={`proj-item ${open ? "open" : ""}`}
onClick={() => setOpenProject(open ? null : p.name)}
>
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium">{p.name}</span>
<span className="proj-count">{faNum(count)} پیشنهاد</span>
</button>
{/* جزئیات دقیقاً زیر همان پروژه باز می‌شود */}
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
<button
type="button"
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 truncate">{p.name}</span>
{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>}
</div>
);
@@ -158,6 +261,13 @@ export function ProjectsPage() {
</div>
)}
</PageLayout>
<AddProjectModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddProject}
loading={addProject.isPending}
/>
</div>
);
}
@@ -166,7 +276,6 @@ export function ProjectsPage() {
function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name);
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
const itemsTotal = (q: Quote) =>
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
@@ -196,10 +305,6 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
);
};
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes
.filter(
(q) =>
+10 -5
View File
@@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox";
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
export function CategoryPage() {
@@ -26,6 +27,8 @@ export function CategoryPage() {
const addSupplier = useAddSupplier();
const removeSupplier = useRemoveSupplier();
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]);
@@ -121,7 +124,7 @@ export function CategoryPage() {
key={s.id}
s={s}
topic={cat}
deletable
deletable={canManage}
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
@@ -130,10 +133,11 @@ export function CategoryPage() {
</div>
)}
<div className="add-panel">
<h4>
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید
</h4>
{canManage && (
<div className="add-panel">
<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="field">
<label>نام تأمین‌کننده *</label>
@@ -182,6 +186,7 @@ export function CategoryPage() {
ذخیره تأمین‌کننده
</Button>
</div>
)}
</PageLayout>
</div>
);

Some files were not shown because too many files have changed in this diff Show More