5 Commits
Author SHA1 Message Date
whqreza 31300728b6 fix(inventory): fix table column shift, show transaction date/time on dashboard cards, and enforce strict multi-layer future date prevention 2026-10-19 23:48:42 +03:30
whqreza 42fb314834 feat(inventory): add mandatory per-section date pickers, prevent future dates, sync transfer dates, and sort by transaction_date 2026-10-19 15:07:11 +03:30
whqreza 264c7cc906 docs: update root, bot, and pm README files for integrated ERP architecture 2026-10-19 11:09:58 +03:30
MMD 707e40e840 fix(org): show the project-membership editor for supervisor/pm roles without panels
Supervisors are scoped by project membership (and are blocked at login
without one), but the editor was hidden unless the user had a panel —
a coordinator could never be given a project. Backend always allowed it.
2026-10-08 11:13:53 +03:30
MMD ca2c1bb8ce fix(db): add missing 'supervisor' value to the user_role enum at startup
CREATE TYPE ... EXCEPTION WHEN duplicate_object no-ops on existing types, so
volumes created before 'supervisor' existed kept an enum without it and
assigning the role 500'd: invalid input value for enum user_role: "supervisor".
ALTER TYPE ... ADD VALUE IF NOT EXISTS is idempotent and safe on any volume.
2026-10-08 11:04:39 +03:30
43 changed files with 938 additions and 5154 deletions
+150 -318
View File
@@ -1,359 +1,191 @@
# ERP & PM Integrated Platform — Dockerized
## سامانه یکپارچه تدارکات، انبارداری، مدیریت پروژه (PM) و ربات هوشمند
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO) مجموعه نرم‌افزاری یکپارچه و خودکفا (Self-contained) شامل تمامی سرویس‌ها، ماژول‌ها و تنظیمات زیرساختی مورد نیاز برای اجرا در محیط توسعه و عملیات:
Self-contained ship package: everything needed to run the stack is in this | سرویس | نام کانتینر | فناوری / کاربرد | پورت در دسترس |
folder — no external files or sibling folders required.
| Service | Container | Tech | Exposed port |
|---|---|---|---| |---|---|---|---|
| **postgres** | `procurement-postgres` | PostgreSQL 18 (one instance: `procurement` + `casdoor` DBs) | `5433` | | **postgres** | `procurement-postgres` | PostgreSQL 18 (دیتابیس مشترک `procurement` + `casdoor`) | `5455:5432` |
| **minio** | `procurement-minio` | MinIO object storage (S3-compatible) | `9000` (API), `9001` (console) | | **minio** | `procurement-minio` | MinIO Object Storage (S3-compatible) برای فایل‌ها و پیش‌فاکتورها | `9000` (API), `9001` (کنسول) |
| **casdoor** | `procurement-casdoor` | Casdoor (OIDC identity provider) | `8080` | | **casdoor** | `procurement-casdoor` | ارائه‌دهنده متمرکز هویت سازمانی (OIDC Provider) | `8080:8000` |
| **api** | `procurement-api` | FastAPI + SQLAlchemy (asyncpg) | `8000` | | **api** | `procurement-api` | بک‌اند اصلی تدارکات و انبار (FastAPI + Asyncpg) | `8000:8000` |
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik | | **frontend** | `procurement-frontend` | فرانت‌اند وب ERP (React 19 + Vite + Tailwind) | سرو از طریق Traefik روی پورت `80` |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) | | **pm-api** | `pm-api` | بک‌اند بومی مدیریت پروژه ساخت (FastAPI + Python 3.12) | درون‌شبکه‌ای (زیرمسیر `/pm/api`) |
| **pm-frontend** | `pm-frontend` | فرانت‌اند پلتفرم PM (React 19 + تقویم شمسی + گانت + QC) | سرو از طریق Traefik روی `/pm` |
| **bot** | `procurement-bot` | ربات هوشمند بله با پردازش صوت و متن (DeepSeek LLM + STT) | ارتباط Long-polling با Bale |
``` | **db-backup** | `procurement-db-backup` | پشتیبان‌گیری خودکار زمان‌بندی‌شده از هر دو پایگاه داده | کانتینر دوره‌ای در پس‌زمینه |
| **traefik** | `procurement-traefik` | ریورس پروکسی و مدیریت ترافیک لایه لبه (Traefik v3) | `80` (HTTP), `443` (HTTPS) |
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)
```
--- ---
## 📁 Layout ## 🌐 معماری شبکه و هدایت ترافیک (Traefik Edge Routing)
```text ```text
v1.3/ ┌────────────────────────┐
├── docker-compose.yml ← all paths are relative to this folder │ Browser / Client │
├── docker-compose.prod.yml ← production overlay (real domains + HTTPS via Let's Encrypt) └───────────┬────────────┘
├── start.ps1 ← PowerShell helper (build + start + status) │ :80 / :443
├── mc.cmd ← MinIO Client helper (inspect the bucket) ▼
├── README.md ┌────────────────────────┐
│ Traefik v3 (Edge) │
└───────────┬────────────┘
│
┌────────────────────────────────────┼───────────────────────────────────┐
│ │ │
▼ (Host: localhost) ▼ (PathPrefix: /pm) ▼ (Host: api.localhost)
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ frontend (React) │ │ pm-frontend / pm-api │ │ api (FastAPI Core) │
│ /api/* Proxy ───────┼────────────┼───────────────────────┼──────────►│ /api/v1/* │
└───────────────────────┘ └───────────────────────┘ └───────────┬───────────┘
│ │
├────────────────────────────────────┬──────────────────────────────────────┤
▼ ▼ ▼
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ Casdoor OIDC (:8080) │ │ MinIO S3 (:9000) │ │ PostgreSQL 18 (:5455) │
│ Host: auth.localhost │ │ Host: s3 / storage │ │ procurement + casdoor │
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
▲
│ ثبت پیش‌نویس استعلام
┌────────┴──────────────┐
│ bot (Bale AI Agent) │
└───────────────────────┘
```
---
## 📁 ساختار پوشه‌ها و اجزای مخزن
```text
ERP/
├── docker-compose.yml ← پیکربندی اصلی اجرای کامل تمام سرویس‌های استک
├── docker-compose.prod.yml ← لایه تنظیمات عملیاتی (Let's Encrypt و دامنه‌های رسمی)
├── docker-compose.coolify.yml ← پیکربندی سازگار با استقرار بر روی Coolify
├── start.ps1 ← اسکریپت هلپر پاورشل برای ساخت و اجرا
├── mc.cmd ← ابزار خط فرمان MinIO Client برای مدیریت باکت‌ها
├── README.md ← همین سند راهنما
├── backend/ ← سرویس بک‌اند اصلی تدارکات و انبار (procurement-api)
│ ├── app/
│ │ ├── db/ ← اتصال دیتابیس و توابع راه‌اندازی اسکیمای خودکار (init_db.py)
│ │ ├── models/ ← مدل‌های پایگاه‌داده (procurement, inventory, catalog)
│ │ ├── routers/ ← اندپوینت‌های API v1 (quotes, inventory, users, drafts, ...)
│ │ ├── schemas/ ← مدل‌های اعتبارسنجی Pydantic
│ │ ├── services/ ← منطق بیزینس (انبارداری، استعلام‌ها، ذخیره‌سازی، ...)
│ │ ├── utils/ ← ابزارهای امنیتی، خطایابی، ریت‌لیمیت
│ │ ├── auth.py ← تایید توکن Casdoor و تفکیک دسترسی‌ها
│ │ ├── config.py ← متغیرهای پیکربندی
│ │ └── main.py ← اسمبلی برنامه FastAPI و میدل‌ورها
│ └── tools/ ← اسکریپت‌های سیدینگ (seed_casdoor.py) و تست‌های دود (Smoke)
├── frontend/ ← وب‌اپلیکیشن اصلی ERP (procurement-frontend)
│ ├── src/
│ │ ├── components/ ← کامپوننت‌های مشترک، لایه‌بندی و سوییچر پنل‌ها
│ │ ├── pages/
│ │ │ ├── Admin/ ← پنل مدیریت سامانه و همگام‌سازی کاربران
│ │ │ ├── Auth/ ← صفحه ورود و احراز هویت
│ │ │ ├── Dashboard/ ← کارتابل استعلام‌ها و تصمیم‌گیری خرید
│ │ │ ├── Inventory/ ← انبارداری (موجودی، ورود کالا، انتقال کارگاهی، کنترل روزانه)
│ │ │ ├── Organization/ ← ماتریس دسترسی پنل‌ها و انتساب پروژه‌ها
│ │ │ ├── Projects/ ← مدیریت پروژه‌های عمرانی
│ │ │ ├── Suppliers/ ← دسته‌بندی موضوعی و فهرست تأمین‌کنندگان
│ │ │ ├── Units/ ← سیستم کاتالوگ متریال‌ها، تامین‌کنندگان و ضرایب تبدیل
│ │ │ └── Wizard/ ← ویزارد مرحله‌ای ثبت استعلام (با اتصال مستقیم به پیش‌نویس ربات)
│ │ ├── store/ ← مدیریت وضعیت با Zustand
│ │ └── App.tsx ← درخت روت‌ها، گیت‌های امنیتی پنل‌ها و نقش‌ها
├── pm/ ← پلتفرم مدیریت پروژه و کنترل کیفیت (پلتفرم آرامش)
│ ├── backend/ ← بک‌اند مستقل PM (FastAPI, CPM Engine, QC Service)
│ ├── frontend/ ← فرانت‌اند PM (گانت چارت شمسی، WBS، ثبت عیوب و بازرسی)
│ ├── sql/ ← اسکیما و اسکریپت‌های اختصاصی پایگاه داده PM
│ └── API_CONTRACT.md ← قرارداد کامل اندپوینت‌های PM
├── bot/ ← ایجنت هوشمند بله برای ثبت صوتی/متنی استعلام‌ها
│ ├── app/ ← کلاینت Bale، پردازشگر STT صوتی، استخراج NLU با DeepSeek
│ ├── docs/SETUP.md ← راهنمای راه‌اندازی و اتصال توکن بله
│ └── requirements.txt
├── monitoring/ ← استک مانیتورینگ، متریک‌ها و لاگ‌ها (پرومتئوس، گرافانا، لوکی)
├── infra/ ← تنظیمات وب‌هوک دیپلوی خودکار و کانفیگ‌های داینامیک Traefik
├── sql/ ├── sql/
│ ├── migration.sql ← DB schema v2.6 (auto-runs on first boot) │ ├── migration.sql ← اسکریپت اولیه پایه دیتابیس
│ └── 02-casdoor-db.sql ← creates the `casdoor` role+DB on the same instance │ └── 02-casdoor-db.sql ← اسکریپت راه‌اندازی دیتابیس هویت Casdoor
├── infra/ └── tests/ ← تست‌های یکپارچگی NLU و ویزارد استعلام
│ └── traefik-dynamic.yml ← Traefik shared middlewares (HTTP→HTTPS redirect)
├── docs/ ← ERDs + migration plan + auth guide
│ ├── erd.txt (v2.4 — old procurement schema)
│ ├── new erd.txt (v2.5 — material → supplier → category)
│ ├── NewERD.txt (v2.6 — integrated procurement + inventory)
│ ├── casdoor-auth.md (v2.6 — Casdoor login, roles, failure scenarios)
│ └── material-supplier-category-migration-plan.md
├── backend/ ← build context of the `api` service
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── .dockerignore
│ ├── app/ (source code — procurement + inventory + auth)
│ └── tools/ (smoke-test + seed scripts, copied into the image)
└── frontend/ ← build context of the `frontend` service
├── Dockerfile
├── nginx.conf
├── .dockerignore
├── .env.example (copy to .env if you need a custom API base URL)
├── index.html
├── package.json
├── package-lock.json
├── tsconfig*.json
├── vite.config.ts
├── public/
└── src/
└── pages/
├── Dashboard/ (procurement office)
├── Wizard/ (add new offer)
├── Suppliers/ (suppliers & topics)
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
``` ```
--- ---
## 🔐 Login ## 🔐 سیستم احراز هویت، ماتریس پنل‌ها و کنترل دسترسی (RBAC)
The whole system is behind a login page (`/login`). Login is **in-page**: سامانه از سیستم احراز هویت **Casdoor OIDC** بهره می‌برد؛ ورود به صورت In-Page بوده و توکن JWT صادر شده توسط بک‌اند با JWKS اعتبارسنجی می‌شود.
the form (username / password) is sent to
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor
(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 مدل دسترسی سامانه بر پایه **پنل‌ها (`user_panels`)** بازطراحی شده است:
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) → * **پنل تدارکات (`procurement`):**
② role switcher (ادمین / مدیر تدارکات / مدیر پروژه + project) → ③ session * `full`: دسترسی کامل به کارتابل استعلام‌ها، تایید یا رد پیشنهادها، تامین‌کنندگان و قیمت‌ها.
activation by role (admin → **پنل مدیریت ادمین** at `/admin`, مدیر تدارکات → * `limited`: فقط امکان ثبت استعلام جدید در ویزارد برای پروژه‌های منتسب به کاربر (کاربر پس از ورود مستقیماً به `/wizard` یا `/requester` هدایت می‌شود).
procurement office, مدیر پروژه → their project's inventory panel). It is * **پنل انبارداری (`inventory`):**
controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the * `full`: مشاهده موجودی همه انبارها، رهگیری «کالاهای در راه» و کارتابل تایید رسید.
`DEV_LOGIN_ENABLED` compose build arg, default `true` — set it to `false` * `limited`: فقط مشاهده و مدیریت انبارهای پروژه‌های تحت عضویت، همراه با دسترسی به ثبت تغییر موجودی کارگاهی (`/inventory/change`).
for production builds). * **پنل مدیریت پروژه (`pm`):**
* دسترسی به مشاهده یا ویرایش تسک‌ها، چارت گانت، ثبت عیوب و فرم‌های QC در `/panel/pm`.
| What | Where | Credentials | ### ۲. قوانین و نقش سرپرست پروژه (`supervisor`)
| --- | --- | --- | * کاربرانی با نقش `supervisor` تنها در صورتی مجاز به ورود به سامانه هستند که حداقل یک پروژه در جدول `user_project_members` یا `projects.pm_user_id` به آن‌ها منتسب شده باشد. در غیر این صورت، پیام عدم تخصیص پروژه نمایش داده شده و اجازه ورود نخواهند داشت.
| 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`) |
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`](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](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
--- ---
## 🌐 Working with the deployed system (server) ## 🚀 راهنمای سریع راه‌اندازی محیط توسعه (Docker)
The production deployment of this stack runs on the server and is reachable ### پیش‌نیازها
at these public addresses (HTTPS): * Docker Desktop (ویندوز) یا Docker Engine + Docker Compose Plugin (لینوکس)
* حداقل ۴ گیگابایت فضای خالی دیسک و اتصال اینترنت برای بارگیری ایمیج‌های اولیه
| 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`](https://www.google.com/search?q=docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
The MinIO console is deliberately **loopback-only** on the server
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
```
You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open.
2. In your browser, open: **http://localhost:9001**
3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](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.
⚠️ Those credentials are **full admin access to the file storage**. They live
only in the server-side `.env`; if one ever leaks, rotate it immediately
(checklist in `docs/SECRET-MANAGEMENT.md`).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
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.
---
## 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)
---
## Start the whole stack
```powershell ```powershell
cd <this-folder> # ۱. رفتن به پوشه پروژه و ساخت/اجرای کانتینرها
docker compose up -d --build docker compose up -d --build
``` # ۲. بررسی وضعیت سلامت سرویس‌ها
First run downloads base images and builds the frontend (takes a few minutes).
Subsequent runs: `docker compose up -d`.
Wait until all services are up:
```powershell
docker compose ps docker compose ps
``` ```
All should show `Up` (postgres also `(healthy)`). تمام سرویس‌ها باید وضعیت `Up` و دیتابیس وضعیت `(healthy)` را نشان دهند.
### درگاه‌ها و آدرس‌های محلی
| بخش | آدرس محلی | توضیحات |
|---|---|---|
| **رابط کاربری ERP** | http://localhost | داشبورد اصلی سامانه |
| **پلتفرم PM** | http://localhost/pm | مدیریت پروژه و کنترل کیفیت (قابل دسترس در `/panel/pm`) |
| **مستندات API (Swagger)** | http://localhost:8000/docs | مستندات تعاملی اندپوینت‌های تدارکات |
| **سلامت API** | http://localhost:8000/health/ready | بررسی اتصال دیتابیس، Casdoor و MinIO |
| **کنسول MinIO** | http://localhost:9001 | نام کاربری و رمز پیش‌فرض: `minioadmin` / `minioadmin` |
| **کنسول Casdoor** | http://localhost:8080 | نام کاربری و رمز اولیه: `admin` / `123` |
| **پایگاه داده PostgreSQL** | `localhost:5455` | نام دیتابیس: `procurement`، کاربر: `postgres` |
--- ---
## Verify ## 🤖 ربات هوشمند بله (`procurement-bot`)
| What | Where | ربات پیام‌رسان بله به عنوان یک سرویس اختصاصی درون شبکه فعال است:
| --- | --- | 1. پیام صوتی یا متنی فرستاده شده از طریق هوش مصنوعی (DeepSeek NLU) و تبدیل صوت به متن پردازش می‌شود.
| Frontend UI | http://localhost | 2. قلم‌ها، متریال‌ها، پروژه‌ها و تامین‌کنندگان به صورت فازی جستجو و شناسایی می‌شوند.
| API docs (Swagger) | http://localhost:8000/docs | 3. پیش‌نویس در جدول `bot_drafts` ذخیره شده و شناسه پیش‌نویس تولید می‌شود.
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` | 4. کاربر می‌تواند با زدن دکمه روی بله یا مراجعه به لینک مستقیم:
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready | `http://localhost/wizard?draft=<UUID>`
| Metrics | http://localhost:8000/metrics | اطلاعات استخراج‌شده را در ویزارد فرانت‌اند مرور، تکمیل و نهایی کند.
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) | 5. راهنمای تکمیلی تنظیم توکن در [bot/docs/SETUP.md](file:///c:/Users/Reza/Desktop/v1.6/bot/docs/SETUP.md) قرار دارد.
| Database | `localhost:5433`, db `procurement`, user/pass `postgres`/`postgres` (Casdoor uses the same instance, db `casdoor`) |
Automated end-to-end checks (authenticates via `SMOKE_USER` /
`SMOKE_PASSWORD` — the seeder users):
```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`](https://www.google.com/search?q=docs/OPERATIONS.md).
--- ---
## MinIO (object storage for invoice / proforma files) ## 🔄 انتقال بین‌کارگاهی کالاها (`project_transfers`)
| Item | Value | سامانه دارای سیستم ردیابی انتقال متریال بین کارگاه‌هاست:
| --- | --- | * ثبت انتقال از طریق صفحه «تغییر موجودی» با نوع `انتقال بین کارگاهی`.
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) | * وضعیت‌های انتقال: `در حال انتقال (in_transit)`، `تحویل‌شده (received)` و `لغوشده (cancelled)`.
| 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` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell
.\mc.cmd ls --recursive
```
--- ---
## Database credentials / connection ## 🛠️ عملیات نگهداری و دیپلوی در سرور (Operations)
```text * **دیپلوی خودکار:** سرور با هر Push به برنچ `production` از طریق وب‌هوک اختصاصی به صورت خودکار بیلد و بالا می‌آید. راهنمای کامل: [docs/OPERATIONS.md](file:///c:/Users/Reza/Desktop/v1.6/docs/OPERATIONS.md).
Host: localhost * **پشتیبان‌گیری پایگاه داده:** سرویس `db-backup` به صورت روزانه از هر دو پایگاه داده `procurement` و `casdoor` فایل‌های `.dump` تولید و در پوشه `backups/` نگهداری می‌کند.
Port: 5433 * **مانیتورینگ و مشاهده‌پذیری:** ابزارهای Prometheus، لاگ‌های Loki و ردیابی خطاهای GlitchTip در پوشه [monitoring/](file:///c:/Users/Reza/Desktop/v1.6/monitoring/README.md) سازمان‌دهی شده‌اند.
Database: procurement
Username: postgres
Password: postgres
```
```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement
```
---
## Recovery / seeding
The databases are **not** in git — Docker volumes hold the data, and
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:
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git:
```powershell
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations
```powershell
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.
* **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`.
```
+6 -71
View File
@@ -384,12 +384,11 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"ON CONFLICT (from_unit, to_unit) DO NOTHING", "ON CONFLICT (from_unit, to_unit) DO NOTHING",
c, c,
) )
# v2.5/v2.6 category decoupling from materials: drop the old supplier-derived # v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
# Category→Material filter cascade (see ensure_dashboard_schema below).
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()") await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
@@ -421,6 +420,9 @@ async def ensure_rbac_schema(engine: AsyncEngine) -> None:
volume — the RBAC model expects these and login 500s without them.""" volume — the RBAC model expects these and login 500s without them."""
stmts = ( stmts = (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT", "ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
# The live user_role enum predates 'supervisor' — CREATE TYPE no-ops on
# an existing type, so without this ALTER, assigning the role 500s.
"ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'supervisor'",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''", "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 project_manager TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE", "ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
@@ -559,70 +561,3 @@ async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("project_transfers table ensured") 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)")
-2
View File
@@ -28,7 +28,6 @@ from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_bot_drafts_table, ensure_bot_drafts_table,
ensure_dashboard_schema,
ensure_drop_category_tables, ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
@@ -110,7 +109,6 @@ async def lifespan(_app: FastAPI):
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_rbac_schema(engine) await ensure_rbac_schema(engine)
await ensure_dashboard_schema(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine) await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine) await ensure_bot_drafts_table(engine)
+1 -7
View File
@@ -130,18 +130,13 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __tablename__ = "materials"
# v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the # v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
# category taxonomy for the dashboard filter cascade (nullable — backfill
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="SET NULL")
)
canonical_unit_id: Mapped[int] = mapped_column( canonical_unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
) )
@@ -152,7 +147,6 @@ class Material(Base):
) )
canonical_unit: Mapped["Unit"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
) )
-2
View File
@@ -4,7 +4,6 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
dashboard,
drafts, drafts,
files, files,
inventory, inventory,
@@ -54,7 +53,6 @@ ROUTERS = [
inventory.router, inventory.router,
pm.router, pm.router,
project_transfers.router, project_transfers.router,
dashboard.router,
users.router, users.router,
drafts.router, drafts.router,
] ]
-212
View File
@@ -1,212 +0,0 @@
"""MVP dashboard router — read-only aggregations for the warehouse +
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
client (frontend/src/lib/api.ts).
Scope (same resolution pattern as the inventory / pm routers):
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
an explicit ?project_id must be one of them.
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
an optional filter.
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
inventory router).
Endpoints:
- GET /dashboard/filters — «What» cascade options
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
- GET /dashboard/inventory/dead-stock — dormant stock table
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
- GET /dashboard/daily-control — today's tickets + performers
- GET /dashboard/procurement/workload — workload per project (density)
Every response is the standard `{"data": …}` envelope.
"""
import uuid
from dataclasses import dataclass
from datetime import date as date_cls
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.services import dashboard_service
from app.utils.errors import (
ForbiddenError,
UnauthorizedError,
ValidationError,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@dataclass
class DashboardScope:
role: str # "pm" | "office"
project_ids: list | None # PM: managed project ids; office: None = all
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
async def dashboard_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژه‌های خودش)"),
db: AsyncSession = Depends(get_db),
) -> DashboardScope:
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
if request.headers.get("authorization"):
user: UserContext | None = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if user.role == "pm":
allowed = list(user.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
if user.role in ("admin", "procurement_manager"):
return DashboardScope(role="office", project_ids=None, project_id=project_id)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager" or role == "admin":
role = "office"
elif role == "pm":
role = "pm"
else:
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
if role == "pm":
raw = request.headers.get("x-project-id")
if not raw:
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
try:
if project_id is None:
project_id = uuid.UUID(str(raw))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
return DashboardScope(role="office", project_ids=None, project_id=project_id)
def _what_filters(
material_id: uuid.UUID | None,
brand_id: uuid.UUID | None,
specs_hash: str | None,
) -> dict:
return {
"material_id": material_id,
"brand_id": brand_id,
"specs_hash": specs_hash,
}
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Options for the global filter bar (Material→Brand→Specs)."""
return await dashboard_service.filter_options(db)
@router.get("/inventory/drilldown")
async def inventory_drilldown(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
material_id: 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(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),
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(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),
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(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,
)
+5 -1
View File
@@ -217,6 +217,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"performed_by": tx.performed_by, "performed_by": tx.performed_by,
"delivered_to": tx.delivered_to, "delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date), "transaction_date": to_iso(tx.transaction_date),
"created_at": to_iso(tx.created_at) if hasattr(tx, "created_at") and tx.created_at else None,
"note": tx.note, "note": tx.note,
} }
@@ -461,7 +462,10 @@ async def list_transactions(
InventoryTransaction.transaction_date InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1) < datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
) )
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") if not sort or sort == "-transaction_date":
stmt = stmt.order_by(InventoryTransaction.transaction_date.desc(), InventoryTransaction.created_at.desc())
else:
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
# Enrich: warehouse name + material/brand/specs/unit for the UI rows. # Enrich: warehouse name + material/brand/specs/unit for the UI rows.
+3 -35
View File
@@ -38,14 +38,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
ItemReceipt, ItemReceipt,
ItemReceiptAllocation, ItemReceiptAllocation,
LineItem, LineItem,
LineItemSpec,
Project, Project,
Quote, Quote,
Supplier, Supplier,
@@ -183,24 +182,16 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
async def pm_on_the_way( async def pm_on_the_way(
ctx: ArrivalScope = Depends(arrivals_scope), ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
): ):
stmt = ( stmt = (
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand) select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
.join(LineItem, LineItem.id == ItemDecision.line_item_id) .join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id) .join(Material, Material.id == LineItem.material_id)
.join(Supplier, Supplier.id == Quote.supplier_id) .join(Supplier, Supplier.id == Quote.supplier_id)
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if 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": if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else []) allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed: if not allowed:
@@ -220,29 +211,8 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
# frozen spec snapshot per line item (key = attribute label)
li_ids = [d.LineItem.id for d in decisions]
specs_by_line: dict = {}
if li_ids:
spec_rows = (
await db.execute(
select(LineItemSpec, AttributeDefinition)
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
.where(LineItemSpec.line_item_id.in_(li_ids))
)
).all()
for spec, ad in spec_rows:
value = spec.value_string
if value is None and spec.value_number is not None:
value = str(spec.value_number)
if value is None and spec.value_boolean is not None:
value = str(spec.value_boolean)
if value is None and spec.value_date is not None:
value = spec.value_date.isoformat()
if value is not None:
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
out = [] out = []
for d, li, q, m, sup, proj, bm, br in decisions: for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
out.append({ out.append({
"item_decision_id": str(d.id), "item_decision_id": str(d.id),
@@ -254,8 +224,6 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name}, "material": {"id": str(m.id), "name": m.name},
"brand": {"id": str(br.id), "name": br.name} if br else None,
"specs": specs_by_line.get(li.id, {}),
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
+19
View File
@@ -122,6 +122,25 @@ async def delete_supplier_material(
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
@router.get("/materials/{material_id}/suppliers")
async def list_material_suppliers(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers # GET /materials/{material_id}/suppliers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-732
View File
@@ -1,732 +0,0 @@
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
Read-only service. Every function receives the resolved scope (PM → the list
of projects they manage; office roles → project_ids=None = whole
organization) plus an optional explicit project filter, and the optional
«What» filters (material_id / brand_id / specs_hash).
Quantities are NEVER mixed across units: inventory rows live in the
material's canonical unit, so stock aggregates carry their unit code and the
UI picks one unit at a time. Numeric results leave the service chart-ready
(floats); identifiers and labels are plain strings.
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
exactly — this module is the server side of that contract.
"""
import uuid
from datetime import date as date_cls
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.catalog import Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
LineItem,
Project,
Quote,
QuoteProjects,
)
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
# daily widgets means today at this offset.
TEHRAN = timezone(timedelta(hours=3, minutes=30))
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
# separately so the UI can stack it as a distinct red segment inside the
# outbound bar. Transfers are excluded from the buckets (they are internal
# moves) but DO count toward the running balance.
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
"""«What» filters for a statement that already joins inventory→material."""
if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id)
if brand_id is not None:
stmt = stmt.where(InventoryItem.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
return stmt
def _scope_filters(stmt, *, project_ids, project_id):
"""warehouse.project_id restriction for the resolved dashboard scope."""
if project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
elif project_ids is not None:
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
return stmt
def _qty(value) -> float:
return round(float(value), 4) if value is not None else 0.0
# ---------------------------------------------------------------------------
# «What» filter cascade — Category → Material → Brand → Specs
# ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict:
"""Options for the global filter bar (Material → Brand → Specs).
Materials come from the catalog (active only). Brand options per material
are the UNION of the catalog links (brand_materials — every brand defined
for that material) and the distinct brands actually present in stock, so
the cascade is complete even for materials whose current stock rows carry
no brand yet. Spec options come from stock rows (inventory.specs_hash),
since specs are a stock-level dimension.
"""
materials = (
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
).scalars().all()
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
inv_rows = (
await db.execute(
select(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Brand.name,
)
.select_from(InventoryItem)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.distinct()
)
).all()
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
catalog_brand_rows = (
await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.select_from(BrandMaterial)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.is_active.is_(True))
)
).all()
brands_by_material: dict[uuid.UUID, dict] = {}
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
for material_id, brand_id, brand_name in catalog_brand_rows:
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
if brand_id is not None:
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
if specs_hash:
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
def material_entry(m: Material) -> dict:
unit = units.get(m.canonical_unit_id)
brands = brands_by_material.get(m.id, {})
spec_map = specs_by_material.get(m.id, {})
return {
"id": str(m.id),
"name": m.name,
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
"brands": [
{"id": str(bid), "name": bname}
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
],
"specs": [
{"hash": h, "specs": sp}
for h, sp in sorted(spec_map.items())
],
}
# فهرست تخت متریال‌ها — سرآغاز آبشار سه‌فیلتری (متریال → برند → مشخصات)
return {"data": {"materials": [material_entry(m) for m in materials]}}
# ---------------------------------------------------------------------------
# Widget 1 — Drill-down matrix (organization → project → warehouse)
# ---------------------------------------------------------------------------
async def inventory_drilldown(
db: AsyncSession,
*,
project_ids,
project_id,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
warehouse, unit) stock aggregation. Active catalog materials without any
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
(labelled with their canonical unit) instead of disappearing in an inner
join.
"""
# تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
# راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
stock = (
select(
InventoryItem.material_id.label("material_id"),
InventoryItem.brand_id.label("brand_id"),
InventoryItem.specs_hash.label("specs_hash"),
InventoryItem.specs.label("specs"),
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code.label("ucode"),
Unit.name_fa.label("uname_fa"),
func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.group_by(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
)
)
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
stock_sq = stock.subquery()
# واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد»)
canon_unit = aliased(Unit)
stmt = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
stock_sq.c.specs,
stock_sq.c.pid,
stock_sq.c.pname,
stock_sq.c.wid,
stock_sq.c.wname,
stock_sq.c.ucode,
stock_sq.c.uname_fa,
func.coalesce(stock_sq.c.qty, 0).label("qty"),
func.coalesce(stock_sq.c.rows, 0).label("rows"),
canon_unit.code.label("canon_ucode"),
canon_unit.name_fa.label("canon_uname_fa"),
)
.select_from(Material)
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
.where(Material.status == "active")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
# موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده)
if brand_id is not None:
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {}
total_rows = 0
materials_tree: dict[str, dict] = {}
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
q = _qty(qty)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
units_legend.setdefault(canon_ucode, canon_uname_fa)
total_by_unit.setdefault(canon_ucode, 0.0)
total_rows += row_count
# Build full material name
parts = [mname]
if bname:
parts.append(bname)
if specs:
parts.extend(str(v) for v in specs.values() if v)
full_name = " ".join(parts)
# Use full_name + unit as unique key for material level, or just full_name
mat_key = f"{mid}_{full_name}"
mat = materials_tree.setdefault(
mat_key,
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
)
if ucode is not None:
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
mat["rows"] += row_count
# مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار)
if pid is None:
continue
proj = mat["_projects"].setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
)
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count
materials = []
for mat in materials_tree.values():
projects_list = []
for proj in mat.pop("_projects").values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects_list.append({**proj, "warehouses": warehouses})
projects_list.sort(key=lambda p: p["name"])
materials.append({**mat, "projects": projects_list})
materials.sort(key=lambda m: m["name"])
return {
"data": {
"units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"materials": materials,
}
}
# ---------------------------------------------------------------------------
# Widget 2 — Dead stock (dormant capital)
# ---------------------------------------------------------------------------
async def dead_stock(
db: AsyncSession,
*,
project_ids,
project_id,
idle_days: int = 60,
material_id=None,
brand_id=None,
specs_hash=None,
limit: int = 200,
) -> dict:
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
Last activity = MAX(inventory_transactions.transaction_date) per row —
served by idx_inventory_tx_inventory_date — falling back to
inventory.updated_at for rows that never posted a transaction.
"""
last_tx = (
select(func.max(InventoryTransaction.transaction_date))
.where(InventoryTransaction.inventory_id == InventoryItem.id)
.correlate(InventoryItem)
.scalar_subquery()
)
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
stmt = (
select(
InventoryItem.id,
InventoryItem.quantity,
InventoryItem.specs,
Material.name,
Brand.name,
Warehouse.name,
Project.name,
Unit.code,
Unit.name_fa,
last_activity.label("last_activity"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
.order_by(last_activity.asc())
.limit(limit)
)
stmt = _stock_filters(
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
rows = (await db.execute(stmt)).all()
now = datetime.now(TEHRAN)
items = []
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
items.append({
"inventory_id": str(inv_id),
"material": mat_name,
"brand": brand_name,
"specs": specs or {},
"warehouse": wh_name,
"project": proj_name,
"quantity": _qty(quantity),
"unit": {"code": ucode, "name_fa": uname_fa},
"last_activity": last.isoformat() if last else None,
"idle_days": (now - last).days if last else None,
})
return {"data": {"idle_days_threshold": idle_days, "items": items}}
# ---------------------------------------------------------------------------
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
# ---------------------------------------------------------------------------
async def daily_flow(
db: AsyncSession,
*,
project_ids,
project_id,
date_from: datetime,
date_to: datetime,
warehouse_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Per-day buckets from the signed ledger.
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
balance = running sum of EVERY signed movement (transfers/found/stock
counts included), seeded with the opening balance before `date_from`.
"""
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 = []
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}}
+11 -5
View File
@@ -22,6 +22,7 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty).
import hashlib import hashlib
import json import json
import uuid import uuid
from datetime import datetime, timezone, timedelta
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
@@ -175,6 +176,11 @@ async def apply_transaction(
t = payload.transaction_type t = payload.transaction_type
specs = payload.specs or {} specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs) specs_hash = canonical_specs_hash(specs)
tx_date = payload.transaction_date or datetime.now(timezone.utc)
# جلوگیری از ثبت تاریخ در آینده
now_utc = datetime.now(timezone.utc)
if tx_date > now_utc + timedelta(minutes=5):
raise ValidationError("امکان ثبت تاریخ یا زمان در آینده وجود ندارد", field="transaction_date")
# STOCK_COUNT: apply the difference between counted and recorded quantity. # STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT": if t == "STOCK_COUNT":
@@ -197,7 +203,7 @@ async def apply_transaction(
recorded_qty=recorded, recorded_qty=recorded,
physical_qty=payload.physical_qty, physical_qty=payload.physical_qty,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
@@ -238,7 +244,7 @@ async def apply_transaction(
quantity=-quantity, quantity=-quantity,
transfer_pair_id=pair_id, transfer_pair_id=pair_id,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(out_tx) db.add(out_tx)
@@ -256,7 +262,7 @@ async def apply_transaction(
quantity=quantity, quantity=quantity,
transfer_pair_id=pair_id, transfer_pair_id=pair_id,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(in_tx) db.add(in_tx)
@@ -291,7 +297,7 @@ async def apply_transaction(
quantity=quantity, quantity=quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to, delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
@@ -321,7 +327,7 @@ async def apply_transaction(
quantity=-quantity, quantity=-quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to, delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
-16
View File
@@ -1100,17 +1100,6 @@ async def quote_stats(
*conditions, *conditions,
) )
) )
# Procurement funnel (cumulative, not the current-state counts above):
# a quote counts as accepted/arrived if it EVER reached that stage.
funnel_row = (
await db.execute(
select(
func.count().label("offered_total"),
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
).where(*conditions)
)
).one()
return { return {
"total": sum(counts.values()), "total": sum(counts.values()),
"offered": counts["offered"], "offered": counts["offered"],
@@ -1119,11 +1108,6 @@ async def quote_stats(
"rejected": counts["rejected"], "rejected": counts["rejected"],
"expired": counts["expired"], "expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None, "average_lead_time_days": int(round(float(avg))) if avg is not None else None,
"funnel": {
"offered_total": funnel_row.offered_total,
"accepted_total": funnel_row.accepted_total,
"arrived_total": funnel_row.arrived_total,
},
} }
-83
View File
@@ -262,11 +262,8 @@ async def run() -> None:
material_by_name: dict[str, Material] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
name=name, name=name,
topic=cat.name,
category_id=cat.id, # v2.8: dashboard Category→Material cascade
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
) )
@@ -444,86 +441,6 @@ async def run() -> None:
)) ))
await db.flush() await db.flush()
# ---- MVP dashboard demo ledger -----------------------------------
# Multi-day history so every widget has real data to show:
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
# - withdrawals/returns/waste over the last ~12 days → flow series
# - tickets dated TODAY (performed_by names) → daily control
# Quantities are hand-checked to keep every row >= 0.
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
mat = material_by_name[MATERIALS[mat_idx][0]]
wh = warehouse_rows[project_idx][wh_idx]
inv = await db.scalar(select(InventoryItem).where(
InventoryItem.warehouse_id == wh.id,
InventoryItem.material_id == mat.id,
InventoryItem.specs_hash == "default",
))
if inv is None:
inv = InventoryItem(
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
specs={}, specs_hash="default",
)
db.add(inv)
await db.flush()
return inv
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
days_ago: int, performed_by: str, note: str | None = None) -> None:
inv.quantity += Decimal(str(qty))
db.add(InventoryTransaction(
inventory_id=inv.id,
warehouse_id=wh.id,
transaction_type=tx_type,
quantity=Decimal(str(qty)),
performed_by=performed_by,
note=note or "تاریخچه دمو داشبورد",
transaction_date=d(days_ago),
))
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
# dead stock: cable + switches bought long ago, untouched since
cable = await ensure_inv(0, 0, 0)
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
keys = await ensure_inv(1, 0, 0)
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
cement = await ensure_inv(4, 2, 0)
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
blocks = await ensure_inv(5, 2, 1)
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
pe16_a = await ensure_inv(6, 1, 0)
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
pe16_b = await ensure_inv(6, 1, 1)
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
pe25 = await ensure_inv(7, 1, 0)
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
await db.flush()
await db.commit() await db.commit()
# summary # summary
+55 -105
View File
@@ -1,120 +1,70 @@
# procurement_ai_agent — Bale chatbot for the procurement ERP # procurement_ai_agent — ایجنت هوشمند بله برای سامانه ERP
A **Bale messenger bot** that turns Persian **chat messages and voice notes** بات پیام‌رسان بله (Bale Messenger Bot) که پیام‌های صوتی و متنی فارسی کاربران را دریافت کرده و با استفاده از مدل‌های هوش مصنوعی (DeepSeek NLU + Whisper STT) آن‌ها را به اقدامات و پیش‌نویس‌های استعلام در سامانه ERP تبدیل می‌کند.
into procurement actions in the
[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) —
draft-first: it captures what it understood, tells you **what it did** and
**what is still empty**, and **commits only after you approve** with a ✅ button.
``` ---
you (Bale, text/voice) ┌─────────────────────────────────┐
│ getUpdates (long poll) │ procurement_ai_agent │ ## 🏗️ وضعیت استقرار در استک ERP
▼ │ ffmpeg → STT (Persian) │
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │ این ایجنت **به صورت سرویس بومی (`procurement-bot`) مستقیماً درون `docker-compose.yml` ریشه پروژه** تعریف شده است و نیازی به نصب یا راه‌اندازی دستی ندارد.
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│ * **نوع اتصال:** Long-polling از سرور بله (`tapi.bale.ai`) — بدون نیاز به باز کردن پورت خارجی، وب‌هوک یا TLS.
└───────────────┬─────────────────┘ * **ارتباط با بک‌اند:** به صورت مستقیم و درون‌شبکه‌ای با سرویس `api` در آدرس `http://api:8000/api/v1`.
│ HTTP + X-Actor-Id * **پایگاه داده:** استفاده از جدول `bot_drafts` در پایگاه‌داده مشترک PostgreSQL.
▼
┌─────────────────────────────────┐ ```text
│ ERP api (/api/v1) │ کاربر (بله: متن یا صوت)
│ quotes · suppliers · materials│ │ getUpdates (Long-polling)
└─────────────────────────────────┘ ▼
┌─────────────────────────────────┐
│ procurement_ai_agent │
│ ffmpeg → STT تبدیل صوت به متن │
│ LLM → استخراج نیت و موجودیت‌ها │
│ Fuzzy Match → انطباق با ERP │
│ ایجاد پیش‌نویس در bot_drafts │
└────────────────┬────────────────┘
│
├── ثبت نهایی از بله با دکمه تایید
└── یا باز کردن مستقیم در ویزارد وب:
/wizard?draft=<UUID>
``` ```
## What it can do ---
| Activity | Example (voice or text) | Committed via | ## 🌟 قابلیت‌ها و کاربردها
| فعالیت | نمونه پیام (صوت یا متن) | اقدام نهایی |
|---|---|---| |---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` | | **ثبت استعلام جدید** | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` | | **تعریف تأمین‌کننده** | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` | | **تعریف متریال جدید** | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` | | **استعلام وضعیت** | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` | | **پذیرش استعلام** | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
### The draft → approve loop ---
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM ## 🔗 حلقه پیش‌نویس و تایید (Draft-First Loop)
extracts a typed **intent + entities** (JSON mode, Persian digits
normalized).
2. Names are fuzzy-matched against the ERP. Several candidates → inline
**pick buttons**; no match → listed as missing.
3. The bot replies with a **review card**: everything captured + a
✅/⚠️/❌ list of what is still empty.
4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and
re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
5. **Nothing is written to the ERP until you tap ✅.** After commit you get the
created quote id and the fields left empty.
### Team access & attribution 1. کاربر پیام صوتی یا متنی می‌فرستد. صوت به متن تبدیل شده و مدل هوش مصنوعی موجودیت‌ها را استخراج می‌کند.
2. اسامی تامین‌کننده، متریال، پروژه و واحدها با کاتالوگ ERP تطبیق داده می‌شوند.
3. کارت مرور پیش‌نویس همراه با فیلدهای مشخص‌شده و اقلام خالی («چه چیزهایی ناقص است») برای کاربر ارسال می‌شود.
4. **دو روش برای تکمیل و ثبت نهایی وجود دارد:**
* **از طریق چت بله:** با ارسال پیام‌های اصلاحی و در نهایت لمس دکمه «✅ ثبت نهایی».
* **از طریق پنل وب ویزارد:** با کلیک روی لینک مستقیم `/wizard?draft=<UUID>` که اطلاعات به صورت خودکار در فرم ۵ مرحله‌ای ویزارد بارگذاری می‌شود و کاربر می‌تواند فایل فاکتور را نیز ضمیمه کند.
Each member links their Bale chat once: `/start` → Casdoor username → password ---
(validated via the ERP's real `POST /auth/login`, used once, never stored).
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
records the **human** who asked.
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink` ## ⚙️ متغیرهای محیطی و پیکربندی
## Quick start (Docker) تنظیمات بات از طریق فایل `.env` در ریشه پروژه خوانده می‌شود:
```bash ```env
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY BALE_BOT_TOKEN=توکن_دریافتی_از_بات_فادر_بله
docker build -t procurement-ai-agent . BALE_API_BASE=https://tapi.bale.ai
docker run -d --name procurement-bot --env-file .env \ AI_BASE_URL=https://api.deepseek.com
--add-host host.docker.internal:host-gateway \ AI_API_KEY=کلید_سرویس_هوش_مصنوعی
procurement-ai-agent AI_LLM_MODEL=deepseek-flash
docker logs -f procurement-bot BALE_ADMIN_CHAT_IDS=شناسه_عددی_مدیران_در_بله
``` ```
Or as part of the ERP stack — see [`integration/`](integration/README.md) راهنمای گام‌به‌گام اتصال و عیب‌یابی در [docs/SETUP.md](docs/SETUP.md) در دسترس است.
(compose service + the one tiny ERP-side patch).
Full setup, env-var reference, flows and troubleshooting:
**[docs/SETUP.md](docs/SETUP.md)**.
## Architecture
```
app/
├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop
├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files)
├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope)
├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions
├── nlu.py Persian intent+entity extraction (JSON-mode chat completions)
├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches
├── drafts.py draft store · QuoteCreate payload builder · missing-field logic
├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
├── texts.py Persian message templates
├── runtime.py context, link store, in-memory pending flows
├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema)
└── handlers/ dispatch · commands · callbacks · links ·
quote · supplier · material · queries · decisions
```
Design notes:
- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling —
no public port, no webhook, resumable offset persisted across restarts.
- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is
converted into the exact `POST /quotes` request body (temp_id
cross-references) only on approval; the API deliberately tolerates
incomplete quotes, so partial commits are explicit, never accidental.
- **Accept flow echoes the termsheet's existing `schedule_steps`** back to
`PATCH /quotes/{id}/accept` (the API replaces steps with what it receives).
- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding,
Arabic ya/ka folding, difflib ranking on top of the API's ilike search.
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before
STT. The LLM provider and the STT provider are configured independently
(e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice).
## Testing
An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler
pipeline with fake updates — no token, DB or API key needed:
```bash
pip install -r requirements.txt
python tools/smoke_test.py # → ALL PASSED: 48 passed
```
-390
View File
@@ -28,7 +28,6 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
@@ -1367,32 +1366,6 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@@ -1741,18 +1714,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2042,69 +2003,6 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -2135,12 +2033,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -2235,133 +2127,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2390,24 +2155,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2454,25 +2201,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/immer": {
"version": "11.1.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
"integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2917,36 +2645,6 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-is": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -3054,57 +2752,6 @@
} }
} }
}, },
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@@ -3237,12 +2884,6 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3329,37 +2970,6 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
-1
View File
@@ -30,7 +30,6 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
-15
View File
@@ -46,10 +46,6 @@ const AdminPanelPage = lazy(() =>
const RequesterHome = lazy(() => const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })), import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
); );
// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجت‌های Power-BI-style
const MvpDashboardPage = lazy(() =>
import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })),
);
function Loading() { function Loading() {
return ( return (
@@ -390,17 +386,6 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route <Route
path="/units" path="/units"
element={ element={
+11 -72
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useEffect, useMemo, useState, type RefObject } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize"; import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside"; import { useClickOutside } from "./useClickOutside";
@@ -17,9 +16,6 @@ export interface ComboboxProps {
value?: string; value?: string;
onChange?: (item: ComboboxItem) => void; onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name} items: ComboboxSource; // آرایه از string یا {id, name}
// شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر
// فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد)
pinnedIds?: string[];
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از + addSuffix?: string; // متن بعد از +
@@ -30,9 +26,6 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی
// (و فیلتر وابسته به آن) به‌تنهایی پاک شود.
onClear?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته) // برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>; inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
@@ -53,7 +46,6 @@ export function Combobox({
value, value,
onChange, onChange,
items, items,
pinnedIds,
allowAdd = true, allowAdd = true,
addPrefix, addPrefix,
addSuffix = "به عنوان گزینه جدید", addSuffix = "به عنوان گزینه جدید",
@@ -64,17 +56,12 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
onClear,
inputRef, inputRef,
openOnFocus = true, openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1); const [highlight, setHighlight] = useState(-1);
// کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز
// می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک
// دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته.
const justOpenedByFocus = useRef(false);
useEffect(() => { useEffect(() => {
setText(value ?? ""); setText(value ?? "");
@@ -92,37 +79,19 @@ export function Combobox({
return all.filter((x) => normalize(x.label).includes(q)); return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]); }, [all, q]);
// آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود.
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
const pin = pinnedIds ?? [];
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
const pinned = pin
.map((id) => all.find((x) => x.id === id))
.filter((x): x is ComboboxItem => x !== undefined);
const pinnedSet = new Set(pinned.map((x) => x.id));
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
}, [all, filtered, pinnedIds]);
const showAdd = const showAdd =
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q); 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 listCount = visible.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false)); const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => { const pick = (it: ComboboxItem) => {
// آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به setText(it.label);
// placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند.
const nextText = it.id ? it.label : "";
setText(nextText);
setOpen(false); setOpen(false);
setHighlight(-1); setHighlight(-1);
onChange?.(it); onChange?.(it);
onInputChange?.(nextText); onInputChange?.(it.label);
}; };
const addCurrent = () => { const addCurrent = () => {
@@ -147,9 +116,9 @@ export function Combobox({
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1)); setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (highlight >= 0 && highlight < visible.length) { if (highlight >= 0 && highlight < filtered.length) {
pick(visible[highlight]); pick(filtered[highlight]);
} else if (highlight === visible.length && showAdd) { } else if (highlight === filtered.length && showAdd) {
addCurrent(); addCurrent();
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -164,7 +133,6 @@ export function Combobox({
ref={inputRef} ref={inputRef}
className={cn( className={cn(
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50", "flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
showClear && "pe-8",
inputClassName, inputClassName,
)} )}
value={text} value={text}
@@ -178,53 +146,24 @@ export function Combobox({
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => { onFocus={() => {
if (openOnFocus && !open) { if (openOnFocus) setOpen(true);
setOpen(true);
justOpenedByFocus.current = true;
}
}}
onClick={() => {
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیک‌های بعدی تاگل می‌کنند
if (justOpenedByFocus.current) {
justOpenedByFocus.current = false;
return;
}
setOpen((o) => !o);
}} }}
onClick={() => setOpen(true)}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{showClear && (
<button
type="button"
aria-label="پاک کردن"
title="پاک کردن"
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setText("");
setHighlight(-1);
setOpen(true);
onClear?.();
}}
>
<X className="h-3.5 w-3.5" />
</button>
)}
{open && ( {open && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg"> <div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && ( {listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div> <div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
)} )}
{visible.map((it, i) => ( {filtered.map((it, i) => (
<button <button
type="button" type="button"
key={it.id + "|" + i} key={it.id + "|" + i}
className={cn( className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg", "block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg", i === highlight && "bg-slate-bg",
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
)} )}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input e.preventDefault(); // حفظ فوکوس input
+166 -52
View File
@@ -21,6 +21,7 @@ export interface JalaliDatePickerProps {
className?: string; className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean; disabled?: boolean;
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
} }
function todayJalali(): [number, number, number] { function todayJalali(): [number, number, number] {
@@ -76,6 +77,7 @@ export function JalaliDatePicker({
className, className,
withTime = false, withTime = false,
disabled = false, disabled = false,
maxDate,
}: JalaliDatePickerProps) { }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
@@ -83,6 +85,62 @@ export function JalaliDatePicker({
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0"))); const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0"))); const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
const maxDateObj = useMemo(() => {
if (!maxDate) return null;
const d = typeof maxDate === "string" ? new Date(maxDate) : maxDate;
return Number.isNaN(d.getTime()) ? null : d;
}, [maxDate]);
const maxDateIso = useMemo(() => {
if (!maxDateObj) return null;
return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`;
}, [maxDateObj]);
const maxJalaliParts = useMemo(() => {
if (!maxDateObj) return null;
return gregorianToJalali(maxDateObj.getFullYear(), maxDateObj.getMonth() + 1, maxDateObj.getDate());
}, [maxDateObj]);
const canGoNextMonth = useMemo(() => {
if (!maxJalaliParts) return true;
const [cy, cm] = curJ;
const [my, mm] = maxJalaliParts;
return cy < my || (cy === my && cm < mm);
}, [curJ, maxJalaliParts]);
const canGoNextYear = useMemo(() => {
if (!maxJalaliParts) return true;
return curJ[0] < maxJalaliParts[0];
}, [curJ, maxJalaliParts]);
const clampIfFuture = (iso: string | null): string | null => {
if (!iso || !maxDateObj) return iso;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
if (d.getTime() > maxDateObj.getTime()) {
return maxDateObj.toISOString();
}
return iso;
};
const getClampedTime = (h: number, m: number, jy: number, jm: number, jd: number): { h: number; m: number } => {
let validH = Math.min(23, Math.max(0, h));
let validM = Math.min(59, Math.max(0, m));
if (maxDateObj && maxDateIso) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (dayIso === maxDateIso) {
const maxH = maxDateObj.getHours();
const maxM = maxDateObj.getMinutes();
if (validH > maxH || (validH === maxH && validM > maxM)) {
validH = maxH;
validM = maxM;
}
}
}
return { h: validH, m: validM };
};
const [text, setText] = useState<string>(() => { const [text, setText] = useState<string>(() => {
if (!value) return ""; if (!value) return "";
const j = isoToJalaliStr(value); const j = isoToJalaliStr(value);
@@ -145,23 +203,30 @@ export function JalaliDatePicker({
const dim = daysInMonth(jy, jm); const dim = daysInMonth(jy, jm);
const today = new Date(); const today = new Date();
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const cells: { d: number; iso: string; isToday: boolean }[] = []; const cells: { d: number; iso: string; isToday: boolean; disabled: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false }); for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false });
for (let d = 1; d <= dim; d++) { for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d); const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
cells.push({ d, iso, isToday: iso === tIso }); const isDisabled = !!(maxDateIso && iso > maxDateIso);
cells.push({ d, iso, isToday: iso === tIso, disabled: isDisabled });
} }
return cells; return cells;
}, [curJ]); }, [curJ, maxDateIso]);
const nav = (dir: string) => { const nav = (dir: string) => {
setCurJ(([jy, jm]) => { setCurJ(([jy, jm]) => {
let ny = jy; let ny = jy;
let nm = jm; let nm = jm;
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1; if (dir === "m-1") {
if (!canGoNextMonth) return [jy, jm, 1];
nm = nm === 12 ? 1 : nm + 1;
}
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1; if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
if (dir === "y-1") ny += 1; if (dir === "y-1") {
if (!canGoNextYear) return [jy, jm, 1];
ny += 1;
}
if (dir === "y+1") ny -= 1; if (dir === "y+1") ny -= 1;
if (nm < 1) { if (nm < 1) {
nm = 12; nm = 12;
@@ -171,15 +236,27 @@ export function JalaliDatePicker({
nm = 1; nm = 1;
ny += 1; ny += 1;
} }
if (maxJalaliParts) {
if (ny > maxJalaliParts[0] || (ny === maxJalaliParts[0] && nm > maxJalaliParts[1])) {
ny = maxJalaliParts[0];
nm = maxJalaliParts[1];
}
}
return [ny, nm, 1]; return [ny, nm, 1];
}); });
}; };
const selectDay = (jy: number, jm: number, jd: number) => { const selectDay = (jy: number, jm: number, jd: number) => {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dayIso > maxDateIso) return;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: validM } = getClampedTime(currentH, currentM, jy, jm, jd);
const validM = Math.min(59, Math.max(0, currentM)); setHourText(faNum(String(validH).padStart(2, "0")));
setMinuteText(faNum(String(validM).padStart(2, "0")));
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd); const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val); onChange(val);
setText( setText(
@@ -200,30 +277,28 @@ export function JalaliDatePicker({
return; return;
} }
const n = parseInt(digits, 10); const n = parseInt(digits, 10);
const clamped = Math.min(23, Math.max(0, n));
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
setHourText(faNum(textToShow));
const [jy, jm, jd] = curJ; const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM)); const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
onChange(buildIso(jy, jm, jd, clamped, validM)); const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : digits;
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); setHourText(faNum(textToShow));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
onChange(buildIso(jy, jm, jd, clampedH, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM));
setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
}; };
const handleHourBlur = () => { const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, ""); const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0; const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(23, Math.max(0, n));
setHourText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ; const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM)); const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
onChange(buildIso(jy, jm, jd, clamped, validM)); setHourText(faNum(String(clampedH).padStart(2, "0")));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`); onChange(buildIso(jy, jm, jd, clampedH, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM));
setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
}; };
const handleMinuteChange = (raw: string) => { const handleMinuteChange = (raw: string) => {
@@ -238,37 +313,46 @@ export function JalaliDatePicker({
return; return;
} }
const n = parseInt(digits, 10); const n = parseInt(digits, 10);
const clamped = Math.min(59, Math.max(0, n));
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
setMinuteText(faNum(textToShow));
const [jy, jm, jd] = curJ; const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
onChange(buildIso(jy, jm, jd, validH, clamped)); const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : digits;
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); setMinuteText(faNum(textToShow));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
onChange(buildIso(jy, jm, jd, validH, clampedM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`);
}; };
const handleMinuteBlur = () => { const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, ""); const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0; const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(59, Math.max(0, n));
setMinuteText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ; const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
onChange(buildIso(jy, jm, jd, validH, clamped)); setMinuteText(faNum(String(clampedM).padStart(2, "0")));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`); onChange(buildIso(jy, jm, jd, validH, clampedM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`);
}; };
const pickToday = () => { const pickToday = () => {
const now = new Date(); const now = new Date();
const [jy, jm, jd] = todayJalali(); const [jy, jm, jd] = todayJalali();
const nh = now.getHours(); let nh = now.getHours();
const nm = now.getMinutes(); let nm = now.getMinutes();
if (maxDateObj) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const todayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && todayIso > maxDateIso) return;
if (todayIso === maxDateIso) {
if (nh > maxDateObj.getHours() || (nh === maxDateObj.getHours() && nm > maxDateObj.getMinutes())) {
nh = maxDateObj.getHours();
nm = maxDateObj.getMinutes();
}
}
}
setHourText(faNum(String(nh).padStart(2, "0"))); setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0"))); setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime const val = withTime
@@ -300,20 +384,23 @@ export function JalaliDatePicker({
} }
const iso = jalaliStrToIso(v); const iso = jalaliStrToIso(v);
if (iso) { if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) { if (!withTime) {
const nums = String(v) const nums = String(v)
.split(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10)); .map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`); let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
onChange(dOnly);
} else { } else {
onChange(iso.slice(0, 10)); onChange(validIso.slice(0, 10));
} }
} else { } else {
onChange(iso); onChange(validIso);
} }
const j = isoToJalali(iso); const j = isoToJalali(validIso);
if (j) setCurJ([j[0], j[1], j[2]]); if (j) setCurJ([j[0], j[1], j[2]]);
} }
}; };
@@ -325,27 +412,32 @@ export function JalaliDatePicker({
} }
const iso = jalaliStrToIso(text); const iso = jalaliStrToIso(text);
if (iso) { if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) { if (!withTime) {
const nums = String(text) const nums = String(text)
.split(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10)); .map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
onChange(dOnly); onChange(dOnly);
setText(isoToJalaliStr(dOnly)); setText(isoToJalaliStr(dOnly));
} else { } else {
onChange(iso.slice(0, 10)); const dOnly = validIso.slice(0, 10);
setText(isoToJalaliStr(iso)); onChange(dOnly);
setText(isoToJalaliStr(dOnly));
} }
} else { } else {
onChange(iso); onChange(validIso);
const j = isoToJalaliStr(iso); const j = isoToJalaliStr(validIso);
const d = new Date(iso); const d = new Date(validIso);
if (!Number.isNaN(d.getTime())) { if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`); setText(`${j} ${faNum(hh + ":" + mm)}`);
setHourText(faNum(hh));
setMinuteText(faNum(mm));
} }
} }
} else if (value) { } else if (value) {
@@ -397,10 +489,22 @@ export function JalaliDatePicker({
<span className="text-[13px] font-bold"> <span className="text-[13px] font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])} {MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span> </span>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}> <button
type="button"
disabled={!canGoNextMonth}
className={cn("rounded px-1.5 text-sm", canGoNextMonth ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextMonth && nav("m-1")}
title={canGoNextMonth ? "ماه بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
› ›
</button> </button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}> <button
type="button"
disabled={!canGoNextYear}
className={cn("rounded px-1.5 text-sm", canGoNextYear ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextYear && nav("y-1")}
title={canGoNextYear ? "سال بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
» »
</button> </button>
</div> </div>
@@ -415,6 +519,16 @@ export function JalaliDatePicker({
{grid.map((cell, i) => {grid.map((cell, i) =>
cell.d === 0 ? ( cell.d === 0 ? (
<div key={i} /> <div key={i} />
) : cell.disabled ? (
<button
key={i}
type="button"
disabled
className="rounded py-0.5 text-[12px] opacity-25 text-muted cursor-not-allowed max-md:py-1.5"
title="امکان انتخاب تاریخ آینده وجود ندارد"
>
{toFa(cell.d)}
</button>
) : ( ) : (
<button <button
key={i} key={i}
@@ -1,183 +0,0 @@
// ============================================================
// ویجت ۴ — کنترل روزانه (امروز)
// کارت‌های 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>
);
}
@@ -1,165 +0,0 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی).
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter, X } from "lucide-react";
import { Combobox, type ComboboxItem } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند.
// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود.
const ALL_OPTION_ID = "";
// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
const LABELS = {
allMaterials: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
cascade: "متریال ← برند ← مشخصات فنی",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
}
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
// فهرست متریال‌ها مستقیماً از کاتالوگ سراسری می‌آید؛ فقط یکتاسازی شناسه‌ها
// انجام می‌شود تا هیچ گزینه‌ی تکراری در فهرست نباشد.
const materials = useMemo(() => {
const seen = new Set<string>();
return (filterQuery.data?.materials ?? []).filter((m) =>
seen.has(m.id) ? false : (seen.add(m.id), true),
);
}, [filterQuery.data]);
const selectedMaterial = useMemo(
() => materials.find((m) => m.id === state.materialId) ?? null,
[materials, state.materialId],
);
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const pick =
(set: (id: string | null, label: string | null) => void) =>
(item: ComboboxItem) =>
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
// فهرست هر فیلد با گزینه‌ی «همه» در ابتدا
const materialItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
[materials],
);
const brandItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allBrands),
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
],
[selectedMaterial],
);
const specItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allSpecs),
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
],
[selectedMaterial],
);
return (
<section
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
aria-label="فیلتر سراسری متریال"
>
<div className="mb-2.5 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
<Filter className="h-3.5 w-3.5" />
</span>
<div>
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
</div>
{activeCount > 0 && (
<button
type="button"
onClick={state.reset}
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
>
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
{LABELS.clearAll}
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
{faNum(activeCount)}
</span>
</button>
)}
</div>
{/* سه فیلتر با عرض یکسان، وسط‌چین و متقارن؛ روی موبایل تک‌ستونی می‌شود */}
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
{/* ۱ — متریال (سرآغاز آبشار) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder={LABELS.allMaterials}
allowAdd={false}
disabled={materials.length === 0}
items={materialItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)}
onChange={pick(state.setMaterial)}
/>
{/* ۲ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)}
onChange={pick(state.setBrand)}
/>
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpecs(null, null)}
onChange={pick(state.setSpecs)}
/>
</div>
</section>
);
}
@@ -1,117 +0,0 @@
// ============================================================
// ویجت ۲ — جدول موجودی راکد (سرمایه‌ی خوابیده)
// ردیف‌هایی با موجودی مثبت که هیچ تراکنشی در بازه‌ی رکود نداشته‌اند.
// آستانه‌ی ۶۰/۹۰ روز با چیپ انتخاب می‌شود؛ روزهای رکود رنگی است.
// ============================================================
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>
);
}
@@ -1,983 +0,0 @@
// ============================================================
// ویجت ۱ — ماتریس Drill-Down سلسله‌مراتبی با نمودار ۱۰۰٪ استک‌شده
// • سطح ۰: محور X = متریال‌ها، استک‌ها = پروژه‌ها
// • سطح ۱: محور X = پروژه‌های متریال انتخاب‌شده، استک‌ها = انبارها
// • قفل هوشمند: با فیلتر فعال انبار، drill-down غیرفعال می‌شود (کلیک بی‌اثر
// و نشانگر پیش‌فرض) چون اسکوپ انبار از قبل بسته شده است.
// • «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار؛ دامنه‌های سراسری
// پروژه/انبار (کنترل‌های چندانتخابی) و متریال انتخاب‌شده به‌صورت تگ حذف‌پذیر.
// فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند و متریال انتخابی با
// هر تغییر آن در برابر payload تازه اعتبارسنجی می‌شود.
// ============================================================
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { XAxisTickContentProps } from "recharts";
import {
CheckSquare,
ChevronDown,
ChevronLeft,
MousePointerClick,
Package,
RotateCcw,
Square,
X,
} from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import type { DrilldownProject } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import {
ChartCard,
ChartEmpty,
ChartLoading,
} from "./chartShared";
import { cn } from "../../lib/utils";
type Level = 0 | 1; // ۰=متریال‌ها (استک: پروژه‌ها)، ۱=پروژه‌ها (استک: انبارها)
// متریال انتخاب‌شده برای drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب
// تگ breadcrumb حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند.
interface DrillNode {
id: string;
name: string;
}
// یک گروه استک (پروژه در نمای کلی، انبار در نمای drill-down) — نام و رنگ از
// payload مشتق می‌شوند
interface StackGroup {
key: string;
name: string;
color: string;
}
// یک میله روی محور X — سهم هر گروه (segments) از مجموع همان واحد.
// کمیت هر گروه علاوه بر segments، به‌صورت پراپرتیِ مسطح و عددی هم روی خودِ شیء
// می‌نشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود.
interface StackedBar {
id: string;
label: string;
unitLabel: string;
total: number;
segments: Record<string, number>;
[segmentKey: string]: number | string | Record<string, number>;
}
// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند)
const ROOT_LEVEL: Level = 0;
const DRILL_LEVEL: Level = 1;
const LABELS = {
root: "کل سازمان",
allProjects: "همه‌ی پروژه‌ها",
allWarehouses: "همه‌ی انبارها",
widgetTitle: "",
projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید",
warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید",
drillHint: "برای مشاهده‌ی سهم پروژه‌ها و انبارها روی میله‌ها کلیک کنید",
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
removeMaterial: "حذف متریال انتخاب‌شده و بازگشت به نمای متریال‌ها",
totalLabel: "مجموع نمایش داده شده",
itemsLabel: "اقلام نمایش داده شده",
tooltipTotal: "مجموع",
tooltipBreakdown: "تفکیک بر اساس",
emptyChart: "موجودی‌ای با این فیلترها یافت نشد",
} as const;
// فراداده‌ی سطوح — برچسب محور X هر سطح (نام‌ها از payload می‌آیند)
const LEVEL_META: Record<Level, { axisTitle: string }> = {
0: { axisTitle: "متریال‌ها" },
1: { axisTitle: "پروژه‌ها" },
};
// ——— تفکیک سگمنت‌ها (استک‌ها) ———
// نوع تفکیک زمینه‌آگاه است: نمای کلی بدون فیلتر پروژه → پروژه‌ها؛ نمای
// drill-down متریال یا نمای دارای فیلتر پروژه → انبارها (تفکیک ریزتر).
type SegmentKind = "project" | "warehouse";
const SEGMENT_META: Record<SegmentKind, { label: string; plural: string }> = {
project: { label: "پروژه", plural: "پروژه‌ها" },
warehouse: { label: "انبار", plural: "انبارها" },
};
function segmentKindFor(level: Level, projectFilterActive: boolean): SegmentKind {
return level === DRILL_LEVEL || projectFilterActive ? "warehouse" : "project";
}
// نام کامل سگمنت انبار — انبارهای هم‌نام در پروژه‌های مختلف با پیشوند پروژه از
// هم تفکیک می‌شوند («مجتمع مسکونی آرام - انبار شمالی»)
const SEGMENT_NAME_SEPARATOR = " - ";
const warehouseSegmentName = (projectName: string, warehouseName: string): string =>
`${projectName}${SEGMENT_NAME_SEPARATOR}${warehouseName}`;
// انبار هم‌نام در «همان پروژه» هم ممکن است (دیتابیس محدودیت یکتایی روی
// (project_id, name) ندارد)؛ فقط در همان حالتِ برخورد، یک شماره‌ی ترتیب پایدار
// به انتهای نام اضافه می‌شود تا راهنما هیچ‌وقت دو برچسب یکسان نداشته باشد.
function disambiguateNames(entries: { key: string; name: string }[]): Map<string, string> {
const keysByName = new Map<string, string[]>();
entries.forEach((entry) => {
const keys = keysByName.get(entry.name) ?? [];
keys.push(entry.key);
keysByName.set(entry.name, keys);
});
const names = new Map<string, string>();
entries.forEach((entry) => {
const twins = keysByName.get(entry.name) ?? [];
names.set(
entry.key,
twins.length > 1 ? `${entry.name} (${faNum(twins.indexOf(entry.key) + 1)})` : entry.name,
);
});
return names;
}
// شناسه‌ی استک در Recharts + سقف عرض میله‌ها به‌ازای هر سطح
const STACK_ID = "dashboard-stack";
const MAX_BAR_SIZE: Record<Level, number> = { 0: 110, 1: 64 };
const BAR_TOP_RADIUS: [number, number, number, number] = [6, 6, 0, 0];
// ——— رنگ گروه‌های استک ———
// رنگ هر گروه فقط از «ترتیب آن در payload» مشتق می‌شود (هیچ نگاشت دستی
// گروه→رنگ وجود ندارد)؛ چرخش طلایی تضمین می‌کند هر تعداد گروه، رنگ‌های
// به‌اندازه‌ی کافی متمایز بگیرند.
const GROUP_HUE_BASE = 232; // هم‌خانواده با رنگ accent سیستم
const GROUP_HUE_STEP = 137.5; // چرخش طلایی
const GROUP_SATURATION = 58;
const GROUP_LIGHTNESS = 52;
function groupColor(index: number): string {
const hue = (GROUP_HUE_BASE + index * GROUP_HUE_STEP) % 360;
return `hsl(${hue.toFixed(1)} ${GROUP_SATURATION}% ${GROUP_LIGHTNESS}%)`;
}
// پسوند شمارش گزینه‌های چند‌انتخابی («… و ۲ پروژه‌ی دیگر»)
const otherProjectsLabel = (count: number) => `${faNum(count)} پروژه‌ی دیگر`;
const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`;
function crumbHint(level: Level, label: string): string {
if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و متریال انتخابی`;
return `حذف «${label}» و بازگشت به نمای ${LEVEL_META[ROOT_LEVEL].axisTitle}`;
}
// برچسب یک دامنه‌ی چند‌انتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش»
function scopeLabel(
options: { id: string; name: string }[],
selectedIds: string[],
allLabel: string,
othersLabel: (count: number) => string,
): string {
if (selectedIds.length === 0) return allLabel;
const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name);
const first = names[0] ?? allLabel;
return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first;
}
// ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ———
// هر میله یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی
// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند.
const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px)
const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px)
const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px)
// شکستن نام بلند به خطوط کوتاه — پایه‌ی تیک محور X
function wrapAxisLabel(text: string): string[] {
const lines: string[] = [];
for (const word of text.split(/\s+/).filter(Boolean)) {
// واژه‌های بسیار بلند هم به تکه‌های قابل نمایش شکسته می‌شوند
const chunks: string[] = [];
for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) {
chunks.push(word.slice(i, i + LABEL_LINE_CHARS));
}
for (const chunk of chunks) {
const last = lines[lines.length - 1];
if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) {
lines[lines.length - 1] = `${last} ${chunk}`;
} else {
lines.push(chunk);
}
}
}
if (lines.length <= LABEL_MAX_LINES) return lines;
const head = lines.slice(0, LABEL_MAX_LINES - 1);
head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`);
return head;
}
// تیک سفارشی محور X — برچسب چندخطی با فاصله‌ی کافی (بدون هم‌پوشانی).
// فاصله‌ی خط اول از محور از tickMargin خودِ XAxis می‌آید و شروع متن با
// dominantBaseline=hanging تراز می‌شود (مثل تیک پیش‌فرض Recharts).
function AxisTick({ x, y, payload }: XAxisTickContentProps) {
const xPos = typeof x === "number" ? x : Number(x);
const yPos = typeof y === "number" ? y : Number(y);
const lines = wrapAxisLabel(String(payload?.value ?? ""));
return (
<text
x={xPos}
y={yPos}
textAnchor="middle"
dominantBaseline="hanging"
fill="#64748b"
fontSize={11}
fontFamily="Vazirmatn"
>
{lines.map((line, i) => (
<tspan key={i} x={xPos} dy={i === 0 ? 0 : LABEL_LINE_HEIGHT}>
{line}
</tspan>
))}
</text>
);
}
// تیک محور Y در نمای ۱۰۰٪ — کسر ۰..۱ به درصد فارسی
function percentTick(value: unknown): string {
const n = Number(value);
return Number.isFinite(n) ? `${faNum(Math.round(n * 100))}٪` : "";
}
// کمیت‌ها هرگز بین واحدها جمع نمی‌شوند: برای هر میله «واحد غالب» (بیشترین
// مجموع) از خود payload انتخاب می‌شود و فقط کمیت‌های همان واحد استک می‌شوند.
function pickDominantUnit(qtyByUnit: Record<string, number>): string | null {
let code: string | null = null;
let best = -Infinity;
for (const [unitCode, value] of Object.entries(qtyByUnit)) {
if (value > best) {
best = value;
code = unitCode;
}
}
return code;
}
// کمیت‌ها همیشه عدد خالص می‌مانند: هر مقدار غیرعددی/نامعتبر (رشته، NaN) از
// محاسبه حذف می‌شود تا میله‌ها هرگز به‌خاطر داده‌ی نامعتبر نامرئی نشوند.
function numericValues(qtyByUnit: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {};
for (const [unitCode, rawValue] of Object.entries(qtyByUnit)) {
const value = Number(rawValue);
if (Number.isFinite(value)) out[unitCode] = value;
}
return out;
}
// تولتیپ سفارشی — کارت شیشه‌ای مینیمال با سلسله‌مراتب روشن:
// سربرگ (موجودیت میله + نوع تفکیک جاری)، بدنه (به‌ازای هر سگمنت: نقطه‌ی رنگ
// داینامیک، نام کامل، کمیت و سهم درصدی) و پاصفحه (جمع میله).
// عرض کارت با محتوا رشد می‌کند (w-max) و فقط با سقف واکنش‌گرا محدود می‌شود؛
// متن‌های بلند (عنوان/نام سگمنت) در همان سقف می‌شکنند تا هرگز از کادر بیرون
// نزنند، و گروه‌های عددی با whitespace-nowrap یک‌تکه می‌مانند تا اعداد و متن
// در حالت RTL به‌هم نریزند.
function CustomTooltip({
active,
label,
payload,
stacks,
segmentKind,
}: {
active?: boolean;
label?: string | number;
payload?: readonly { payload?: StackedBar }[];
stacks: StackGroup[];
segmentKind: SegmentKind;
}) {
if (!active || !payload?.length) return null;
const bar = payload[0]?.payload;
if (!bar) return null;
const rows = stacks
.map((stack) => ({ stack, value: bar.segments[stack.key] ?? 0 }))
.filter((row) => row.value > 0);
if (rows.length === 0) return null;
const total = rows.reduce((sum, row) => sum + row.value, 0);
return (
<div
dir="rtl"
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[320px] sm:whitespace-nowrap"
>
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
<div className="flex flex-col gap-0.5">
<span className="whitespace-normal text-xs font-bold text-slate-800">
{String(label ?? bar.label)}
</span>
<span className="text-[10px] text-slate-500 sm:text-xs">
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
</span>
</div>
<div className="my-2 border-b border-slate-100" />
{/* ردیف هر سگمنت: نام سمت راست، کمیت و درصد چسبیده به هم سمت چپ */}
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div key={row.stack.key} className="flex w-full items-center justify-between gap-6">
<div className="flex items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: row.stack.color }}
/>
<span className="whitespace-normal text-[11px] font-medium text-slate-700 sm:text-xs">
{row.stack.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{fmtNum(row.value)} {bar.unitLabel}
</span>
<span className="whitespace-nowrap text-[11px] tabular-nums text-slate-700 sm:text-xs">
{faNum(Math.round((row.value / total) * 100))}٪
</span>
</div>
</div>
))}
</div>
<div className="my-2 border-b border-slate-100" />
{/* جمع میله — «مجموع: ۸۰۰ متر» کنار هم و از سمت راست */}
<div className="flex w-full items-center justify-start gap-1.5 text-xs font-semibold text-slate-600">
<span>{LABELS.tooltipTotal}:</span>
<span className="whitespace-nowrap tabular-nums">
{fmtNum(total)} {bar.unitLabel}
</span>
</div>
</div>
);
}
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component ---
// کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb
// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را
// آزاد می‌کند. کلیک‌های درون منو با stopPropagation جدا می‌شوند تا هرگز به
// handlerهای نمودار نرسند.
function MultiSelectDropdown({
label,
options,
selectedIds,
onChange,
disabled,
allLabel,
onClearAll,
title,
}: {
label: string;
options: { id: string; name: string }[];
selectedIds: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
// با ارائه‌ی این دو، ردیف «همه‌ی …» در ابتدای فهرست ظاهر می‌شود
allLabel?: string;
onClearAll?: () => void;
title?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const toggleItem = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((x) => x !== id));
} else {
onChange([...selectedIds, id]);
}
};
return (
<div className="relative inline-block" ref={ref}>
<button
type="button"
disabled={disabled}
title={title}
onClick={() => setOpen(!open)}
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
disabled ? "cursor-not-allowed opacity-50" : "hover:bg-slate-bg hover:text-accent cursor-pointer",
selectedIds.length > 0 ? "text-accent" : "text-ink"
)}
>
<span>{label}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 max-h-60 w-56 overflow-auto rounded-lg border border-line bg-panel p-1 shadow-xl">
{allLabel && onClearAll && (
<>
<button
type="button"
onClick={(event) => {
// رویداد منو هرگز نباید به کلیک‌های نمودار برسد
event.stopPropagation();
onClearAll();
setOpen(false);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs font-bold text-accent transition-colors hover:bg-slate-bg"
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="truncate">{allLabel}</span>
</button>
<div className="my-1 border-t border-line" />
</>
)}
{options.length === 0 ? (
<div className="p-2 text-center text-xs text-muted">گزینه‌ای یافت نشد</div>
) : (
options.map((opt) => {
const isSelected = selectedIds.includes(opt.id);
return (
<div
key={opt.id}
onClick={(event) => {
// رویداد انتخاب گزینه هم ایزوله می‌شود
event.stopPropagation();
toggleItem(opt.id);
}}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-slate-bg"
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-accent" />
) : (
<Square className="h-4 w-4 text-muted" />
)}
<span className="truncate">{opt.name}</span>
</div>
);
})
)}
</div>
)}
</div>
);
}
export function DrilldownMatrix() {
const query = useDrilldown();
// متریال انتخاب‌شده (سطح ۱) — تنها حالت محلی نمودار؛ سطح از آن مشتق می‌شود
const [selectedMaterial, setSelectedMaterial] = useState<DrillNode | null>(null);
const level: Level = selectedMaterial ? DRILL_LEVEL : ROOT_LEVEL;
const materialId = selectedMaterial?.id ?? null;
// فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
// تفکیک جاری نمودار: نمای کلی بدون فیلتر پروژه → پروژه‌ها؛ نمای drill-down یا
// نمای دارای فیلتر پروژه → انبارها
const segmentKind = segmentKindFor(level, selectedProjectIds.length > 0);
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
const plotRef = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
const data = query.data;
// گزینه‌های آبشاری از دل payload ساخته می‌شوند
const projectOptions = useMemo(() => {
if (!data) return [];
const map = new Map<string, string>();
data.materials.forEach((m) => {
m.projects.forEach((p) => {
map.set(p.id, p.name);
});
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data]);
const warehouseOptions = useMemo(() => {
if (!data) return [];
const map = new Map<string, string>();
data.materials.forEach((m) => {
m.projects.forEach((p) => {
if (selectedProjectIds.length === 0 || selectedProjectIds.includes(p.id)) {
p.warehouses.forEach((w) => {
map.set(w.id, w.name);
});
}
});
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data, selectedProjectIds]);
// ——— همگام‌سازی با فیلتر سراسری (Zustand) ———
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی انتخاب‌ها، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه
// در سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
// که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم.
useEffect(() => {
if (!data) return;
const validProjectIds = new Set(projectOptions.map((o) => o.id));
setSelectedProjectIds((ids) =>
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
);
const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id));
setSelectedWarehouseIds((ids) =>
ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)),
);
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
// ۲) اگر متریال انتخاب‌شده از payload حذف شده باشد، نمودار به نمای متریال‌ها
// برمی‌گردد؛ انتخاب معتبر دست‌نخورده می‌ماند.
useEffect(() => {
if (!data || !selectedMaterial) return;
if (!data.materials.some((m) => m.id === selectedMaterial.id)) {
setSelectedMaterial(null);
}
}, [globalFilterKey, data, selectedMaterial]);
// ——— اندازه‌گیری عرض کانتینر نمودار ———
// عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر
// اسکرول‌دار است، پس با هر تغییر اندازه‌ی پنجره/کارت دوباره اندازه‌گیری می‌شود.
useEffect(() => {
const el = plotRef.current;
if (!el) return;
const measure = () => setPlotWidth(el.clientWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
// ——— داده‌ی نمودار ۱۰۰٪ استک‌شده ———
// محورها، سگمنت‌ها (پروژه/انبار بر اساس segmentKind)، نام‌های کامل، واحدها
// و رنگ‌ها همه از payload مشتق می‌شوند؛ هیچ نگاشت دستی در کد وجود ندارد.
const { bars, stacks, unitLabels } = useMemo(() => {
const empty = {
bars: [] as StackedBar[],
stacks: [] as StackGroup[],
unitLabels: [] as string[],
};
if (!data) return empty;
const matchesProject = (pid: string) =>
selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
const matchesWarehouse = (wid: string) =>
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
interface GroupAcc {
name: string;
byUnit: Record<string, number>;
}
const barsAcc = new Map<
string,
{ id: string; label: string; ownUnits: Record<string, number>; groups: Map<string, GroupAcc> }
>();
// هر میله حتی بدون گروه هم ساخته می‌شود تا برچسبش روی محور بماند
// (متریال‌های کاتالوگی با موجودی صفر هم روی محور دیده می‌شوند).
const ensureBar = (id: string, label: string, ownUnits: Record<string, number>) => {
let bar = barsAcc.get(id);
if (!bar) {
bar = { id, label, ownUnits: numericValues(ownUnits), groups: new Map() };
barsAcc.set(id, bar);
}
return bar;
};
const addGroup = (
bar: { groups: Map<string, GroupAcc> },
groupId: string,
groupName: string,
qtyByUnit: Record<string, number>,
) => {
let group = bar.groups.get(groupId);
if (!group) {
group = { name: groupName, byUnit: {} };
bar.groups.set(groupId, group);
}
for (const [unitCode, value] of Object.entries(numericValues(qtyByUnit))) {
group.byUnit[unitCode] = (group.byUnit[unitCode] ?? 0) + value;
}
};
// میله‌ها و سگمنت‌ها با یک الگوی واحد ساخته می‌شوند: میله = متریال (نمای
// کلی) یا پروژه (نمای drill-down)، و سگمنت بر اساس تفکیک زمینه‌آگاه
// (segmentKind) پروژه یا انبار است.
const addBar = (
barId: string,
barLabel: string,
ownUnits: Record<string, number>,
projects: DrilldownProject[],
) => {
const bar = ensureBar(barId, barLabel, ownUnits);
projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return;
if (segmentKind === "project") {
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
} else {
// سگمنت انبار: نام کامل «پروژه - انبار» تا انبارهای هم‌نام در
// پروژه‌های مختلف در راهنما و تولتیپ قابل تفکیک باشند
addGroup(bar, wh.id, warehouseSegmentName(proj.name, wh.name), wh.qty_by_unit);
}
});
});
};
if (level === ROOT_LEVEL) {
// میله = متریال، سگمنت = پروژه (نمای کلی) یا انبار (وقتی فیلتر پروژه فعال است)
data.materials.forEach((mat) => addBar(mat.id, mat.name, mat.qty_by_unit, mat.projects));
} else {
// میله = پروژه‌ی متریال انتخاب‌شده، سگمنت = انبار
const mat = data.materials.find((m) => m.id === materialId);
mat?.projects.forEach((proj) => addBar(proj.id, proj.name, proj.qty_by_unit, [proj]));
}
const groupNames = new Map<string, string>();
const bars: StackedBar[] = [];
for (const bar of barsAcc.values()) {
// واحد غالب میله: از مجموع گروه‌ها و در نبود گروه، از واحد خودِ موجودیت
const unitTotals: Record<string, number> = { ...bar.ownUnits };
for (const group of bar.groups.values()) {
for (const [unitCode, value] of Object.entries(group.byUnit)) {
unitTotals[unitCode] = (unitTotals[unitCode] ?? 0) + value;
}
}
const unitCode = pickDominantUnit(unitTotals);
const segments: Record<string, number> = {};
let total = 0;
for (const [groupId, group] of bar.groups) {
const value = unitCode ? group.byUnit[unitCode] ?? 0 : 0;
segments[groupId] = value;
total += value;
groupNames.set(groupId, group.name);
}
bars.push({
id: bar.id,
label: bar.label,
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
total,
segments,
// مسطح‌سازی برای Recharts: هر <Bar dataKey={stack.key}> مقدار عددی را
// مستقیماً از سطح پراپرتی‌های همین شیء می‌خواند
...segments,
});
}
bars.sort((a, b) => b.total - a.total);
// فهرست استک‌ها = اجتماع گروه‌های حاضر؛ ترتیب (و بنابراین رنگ‌ها) از مجموع
// کمیت‌ها مشتق می‌شود و در حالت تساوی با نام، پایدار می‌ماند.
const stackTotals = new Map<string, number>();
for (const bar of bars) {
for (const [groupId, value] of Object.entries(bar.segments)) {
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
}
}
const orderedKeys = [...stackTotals.entries()]
.sort(
(a, b) =>
b[1] - a[1] ||
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
)
.map(([key]) => key);
// نام‌های تکراری (انبار هم‌نامِ همان پروژه) پیش از ساخت راهنما تفکیک می‌شوند
const uniqueNames = disambiguateNames(
orderedKeys.map((key) => ({ key, name: groupNames.get(key) ?? key })),
);
const stacks: StackGroup[] = orderedKeys.map((key, index) => ({
key,
name: uniqueNames.get(key) ?? groupNames.get(key) ?? key,
color: groupColor(index),
}));
return {
bars,
stacks,
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
};
}, [data, level, materialId, segmentKind, selectedProjectIds, selectedWarehouseIds]);
// عرض صریح نمودار: هر میله یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و
// برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
// کانتینر (و کف تعیین‌شده) نمی‌شود.
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
// ——— قفل هوشمند drill-down ———
// اگر کاربر انبار(های) مشخصی را در دامنه‌ی سراسری فیلتر کرده باشد، عمیق‌تر
// رفتن روی نمودار بی‌معناست؛ کلیک بی‌اثر و نشانگر پیش‌فرض می‌شود.
const drillLocked = selectedWarehouseIds.length > 0;
const handleBarClick = (entry: unknown) => {
// قفل فعال → هیچ پرشی انجام نمی‌شود
if (drillLocked) return;
// سطح پروژه‌ها، سطح پایانی است؛ فقط از نمای متریال‌ها drill می‌شویم
if (level !== ROOT_LEVEL) return;
const bar = (entry as { payload?: StackedBar })?.payload;
if (!bar) return;
setSelectedMaterial({ id: bar.id, name: bar.label });
};
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک می‌شوند
const resetToRoot = () => {
setSelectedMaterial(null);
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// حذف تگ متریال — فقط حالت محلی نمودار صفر می‌شود و دامنه‌های سراسری دست‌نخورده می‌مانند
const clearMaterial = () => setSelectedMaterial(null);
// دامنه‌ی پروژه فقط دامنه‌ی انبارِ زیرمجموعه‌اش را آزاد می‌کند
const handleProjectScopeChange = (ids: string[]) => {
setSelectedProjectIds(ids);
if (ids.length === 0) setSelectedWarehouseIds([]);
};
// ردیف «همه‌ی پروژه‌ها» در منوی گره: دامنه‌ی پروژه و دامنه‌ی عمیق‌ترش (انبار) آزاد می‌شود
const clearProjectScope = () => {
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و قفل drill-down برداشته می‌شود
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
const projectScopeLabel = () =>
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
const warehouseScopeLabel = () =>
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
// راهنمای تعامل با نمودار — هماهنگ با سطح جاری و قفل هوشمند
const drillHint = (): string => {
if (level !== ROOT_LEVEL) return "";
return drillLocked ? LABELS.lockedHint : LABELS.drillHint;
};
// جمع زدن کمیت‌های با واحدهای متفاوت بی‌معناست؛ در آن حالت تعداد اقلام می‌آید
const totalText =
unitLabels.length === 1
? `${LABELS.totalLabel}: ${fmtNum(bars.reduce((sum, bar) => sum + bar.total, 0))} ${unitLabels[0]}`
: `${LABELS.itemsLabel}: ${faNum(bars.length)}`;
// ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک)
const crumbClass = (isCurrent: boolean) =>
cn(
"max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]",
isCurrent
? "bg-accent-bg text-accent hover:bg-accent-bg/70"
: "text-muted hover:bg-slate-bg hover:text-ink",
);
return (
<ChartCard title={LABELS.widgetTitle}>
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و متریال انتخاب‌شده
به‌صورت تگ حذف‌پذیر می‌آید؛ ✕ و ریشه هر دو انتخاب متریال را صفر می‌کنند. */}
<nav
dir="rtl"
aria-label="مسیر پیمایش گزارش"
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
>
{/* ریشه — بازگشت کامل به کل سازمان */}
<button
type="button"
onClick={resetToRoot}
aria-current={level === ROOT_LEVEL ? "page" : undefined}
title={crumbHint(ROOT_LEVEL, LABELS.root)}
className={crumbClass(level === ROOT_LEVEL)}
>
{LABELS.root}
</button>
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چند‌انتخابی، برچسب بازتاب انتخاب‌ها */}
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={projectScopeLabel()}
options={projectOptions}
selectedIds={selectedProjectIds}
onChange={handleProjectScopeChange}
allLabel={LABELS.allProjects}
onClearAll={clearProjectScope}
title={LABELS.projectScopeHint}
/>
{/* گره‌ی دامنه‌ی انبار — فقط وقتی دامنه‌ی پروژه/انبار فعال است */}
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={warehouseScopeLabel()}
options={warehouseOptions}
selectedIds={selectedWarehouseIds}
onChange={setSelectedWarehouseIds}
allLabel={LABELS.allWarehouses}
onClearAll={clearWarehouseScope}
title={LABELS.warehouseScopeHint}
/>
</>
)}
{/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */}
{selectedMaterial && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
<button
type="button"
onClick={clearMaterial}
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
>
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
</button>
<button
type="button"
onClick={(event) => {
// ✕ نباید به handler بدنه‌ی تگ برسد
event.stopPropagation();
clearMaterial();
}}
aria-label={LABELS.removeMaterial}
title={LABELS.removeMaterial}
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
>
<X aria-hidden className="h-3 w-3" />
</button>
</span>
</>
)}
<span className="ms-auto text-[11px] tabular-nums text-muted">{totalText}</span>
</nav>
{/* میله‌های صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط
وقتی است که هیچ میله‌ای با فیلترها هم‌خوانی نداشته باشد */}
{query.isLoading ? (
<ChartLoading />
) : !bars.length ? (
<ChartEmpty message={LABELS.emptyChart} />
) : (
<>
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
میله‌ها پخش شوند و برچسب‌های فاصله‌ی کافی داشته باشند.
ریشه‌ی حلقه‌ی مشکی فوکوس: Recharts لایه‌های z-index را به‌صورت
<g tabindex="-1" class="recharts-zIndex-layer_*"> رندر می‌کند
(ZIndexPortal.js) و آن g کل ناحیه‌ی نمودار را می‌پوشاند؛ پس با
کلیک، فوکوس روی همین g می‌نشیند و مرورگر حلقه‌ی پیش‌فرض را دور کل
نمودار می‌کشد. چون outline ارث‌بری نمی‌شود، مهار آن فقط با
سلکتور فرزند (g/سایر عناصر داخل نمودار) ممکن است. */}
<div
ref={plotRef}
dir="ltr"
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
style={{ width: '100%', height: CHART_HEIGHT }}
>
{/* بدون لایه‌ی دسترس‌پذیری، Recharts روی خود SVG دیگر tabIndex
نمی‌گذارد؛ ولی لایه‌های z-index داخلی‌اش (g[tabindex="-1"]) هنوز
فوکوس‌پذیرند و همان‌ها منبع حلقه‌ی فوکوس‌اند — که با سلکتورهای
فرزند روی wrapper مهار شده‌اند. */}
<BarChart
width={chartWidth}
height={CHART_HEIGHT}
data={bars}
stackOffset="expand"
accessibilityLayer={false}
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="label"
tick={AxisTick}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={AXIS_HEIGHT}
tickMargin={AXIS_TICK_MARGIN}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={percentTick}
tickLine={false}
axisLine={false}
width={56}
domain={[0, 1]}
tickCount={5}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={(props) => (
<CustomTooltip {...props} stacks={stacks} segmentKind={segmentKind} />
)}
/>
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشه‌های گرد فقط
برای بالاترین گروه (آخرین Bar) اعمال می‌شود */}
{stacks.map((stack, index) => (
<Bar
key={stack.key}
dataKey={stack.key}
stackId={STACK_ID}
fill={stack.color}
radius={index === stacks.length - 1 ? BAR_TOP_RADIUS : 0}
maxBarSize={MAX_BAR_SIZE[level]}
isAnimationActive={false}
onClick={handleBarClick}
className={cn(
"transition-opacity",
drillLocked ? "cursor-default" : "cursor-pointer hover:opacity-90",
)}
/>
))}
</BarChart>
</div>
{/* راهنمای رنگ‌ها — از همان سگمنت‌های payload ساخته می‌شود و نام
انبارها به‌صورت کامل «پروژه - انبار» نمایش داده می‌شود */}
{stacks.length > 0 && (
<div
dir="rtl"
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
>
<span className="font-bold">
{LEVEL_META[level].axisTitle} × {SEGMENT_META[segmentKind].plural}:
</span>
{stacks.map((stack) => (
<span key={stack.key} className="inline-flex items-center gap-1">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: stack.color }} />
<span className="max-w-[16rem] truncate" title={stack.name}>
{stack.name}
</span>
</span>
))}
</div>
)}
{drillHint() && (
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
<MousePointerClick className="h-3.5 w-3.5" />
{drillHint()}
</p>
)}
</>
)}
</ChartCard>
);
}
@@ -1,352 +0,0 @@
// ============================================================
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
// ============================================================
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 { fmtNum } from "../../lib/formatters";
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
const FLOW_COLORS = {
inbound: "#059669", // ورود به انبار — زمردی
returns: "#0d9488", // برگشت از سایت — تیل
siteOut: "#f59e0b", // خروج به سایت — کهربایی
waste: "#dc2626", // ضایعات و اتلاف — قرمز
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
balance: "#0f172a", // خط مانده — سرمهای
} as const;
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
const LABELS = {
title: "جریان روزانه موجودی",
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
inbound: "ورود به انبار",
returns: "برگشت از سایت",
siteOut: "خروج به سایت",
waste: "ضایعات و اتلاف",
other: "سایر حرکات (انتقال/شمارش)",
balance: "مانده",
opening: "مانده ابتدای روز",
net: "تغییر خالص روز",
closing: "مانده پایان روز",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
} as const;
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> به‌عنوان مقدار محدوده می‌خواند
type Range = [number, number];
// یک ردیف زمانی — سگمنتها به‌صورت پراپرتی‌های مسطح می‌نشینند تا dataKey هر
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
interface FlowRow {
date: string;
balance: number; // خط پله‌ای مانده = مانده پایان روز
opening: number;
closing: number;
net: number;
inbound: number;
returns: number;
siteOut: number;
waste: number;
other: number;
hasMovement: boolean;
segInbound: Range;
segReturns: Range;
segSiteOut: Range;
segWaste: Range;
segOther: Range;
}
// گرد کردن تا ۴ رقم اعشار — مثل بقیه‌ی تجمیع‌های داشبورد
const round4 = (v: number) => Math.round(v * 10000) / 10000;
// حاشیه‌ی دامنه‌ی محور Y (نسبت به بازه‌ی داده) تا لبه‌ها به قاب نچسبند
const DOMAIN_PAD_RATIO = 0.04;
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور می‌آید
const BAR_CATEGORY_GAP = "18%";
// همه‌ی سگمنت‌ها یک ستون مشترک می‌سازند (نه چند ستون باریک کنار هم)؛ چون
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
// دقیقاً در همان ارتفاع شناور رسم می‌کند.
const FLOW_STACK_ID = "flow-waterfall";
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
function FlowTooltip({
active,
payload,
}: {
active?: boolean;
payload?: readonly { payload?: FlowRow }[];
}) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
];
if (row.other !== 0) {
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
}
const netTone =
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
return (
<div
dir="rtl"
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
>
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</div>
<div className="my-2 border-b border-slate-100" />
<div className="flex flex-col gap-1.5">
{lines.map((line) => (
<div key={line.label} className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
{line.label}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
>
{line.value === 0 ? fmtNum(0) : signed(line.value)}
</span>
</div>
))}
<div className="my-0.5 border-b border-slate-100" />
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.net}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
>
{signed(row.net)}
</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.closing}
</span>
<span
dir="ltr"
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
>
{fmtNum(row.closing)}
</span>
</div>
</div>
</div>
);
}
export function FlowChart() {
const query = useDailyFlow();
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار می‌شود ———
const rows: FlowRow[] = useMemo(() => {
const days = query.data?.days ?? [];
let running = query.data?.opening_balance ?? 0;
return days.map((d) => {
const opening = running;
const closing = d.balance;
const waste = Math.max(0, d.waste);
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته می‌شود
const siteOut = Math.max(0, d.outbound - waste);
const net = round4(closing - opening);
// «سایر حرکات» = باقی‌مانده‌ی تراز (انتقال/شمارش/یافت‌شده)؛ معمولاً صفر
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
let cursor = opening;
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
const segment = (delta: number): Range => {
const from = cursor;
cursor = round4(cursor + delta);
return [Math.min(from, cursor), Math.max(from, cursor)];
};
const segInbound = segment(d.inbound);
const segReturns = segment(d.returns);
const segSiteOut = segment(-siteOut);
const segWaste = segment(-waste);
const segOther = segment(other);
running = closing;
return {
date: d.date,
balance: closing,
opening,
closing,
net,
inbound: d.inbound,
returns: d.returns,
siteOut,
waste,
other,
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
segInbound,
segReturns,
segSiteOut,
segWaste,
segOther,
};
});
}, [query.data]);
// ——— دامنه‌ی محور Y از خود داده (نه از صفر) تا ستون‌ها و خط در کادر جا شوند ———
const [domainMin, domainMax] = useMemo(() => {
const values: number[] = [];
rows.forEach((r) => {
values.push(r.balance, r.opening, r.closing);
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
values.push(lo, hi);
});
});
if (values.length === 0) return [0, 1] as const;
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
}, [rows]);
const hasMovement = rows.some((r) => r.hasMovement);
const hasOther = rows.some((r) => r.other !== 0);
return (
<ChartCard title={LABELS.title} sub={LABELS.sub}>
{query.isLoading ? (
<ChartLoading />
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : (
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
minTickGap={18}
/>
<YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={<FlowTooltip />}
/>
<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="segInbound"
name={LABELS.inbound}
fill={FLOW_COLORS.inbound}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segReturns"
name={LABELS.returns}
fill={FLOW_COLORS.returns}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segSiteOut"
name={LABELS.siteOut}
fill={FLOW_COLORS.siteOut}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segWaste"
name={LABELS.waste}
fill={FLOW_COLORS.waste}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده می‌شود */}
{hasOther && (
<Bar
dataKey="segOther"
name={LABELS.other}
fill={FLOW_COLORS.other}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
)}
{/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
<Line
type="stepAfter"
dataKey="balance"
name={LABELS.balance}
stroke={FLOW_COLORS.balance}
strokeWidth={1.75}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
}
@@ -1,125 +0,0 @@
// ============================================================
// ویجت ۶ — شبکه‌ی وضعیت سفارش‌های در راه
// جدول کمی سفارش‌های باز: متریال و تأمین‌کننده | سفارش‌شده | رسیده
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
// داده از /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>
);
}
@@ -1,42 +0,0 @@
// ============================================================
// ویجت ۵ — 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>
);
}
@@ -1,124 +0,0 @@
// ============================================================
// ویجت ۷ — قیف تدارکات
// تبدیل تجمعی: کل درخواست‌ها (پیشنهادشده) → پذیرفته‌شده → رسیده به انبار
// از /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>
);
}
@@ -1,123 +0,0 @@
// ============================================================
// ویجت ۸ — تراکم پروژه‌ها (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>
);
}
@@ -1,181 +0,0 @@
// ============================================================
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ 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>
);
}
@@ -101,7 +101,6 @@ export function MobileTabBar() {
if (pathname === "/wizard" || pathname.startsWith("/review")) return null; if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage = const onOfficePage =
pathname === "/" || pathname === "/" ||
pathname === "/dashboard" ||
pathname === "/projects" || pathname === "/projects" ||
pathname === "/units" || pathname === "/units" ||
pathname.startsWith("/suppliers"); pathname.startsWith("/suppliers");
-90
View File
@@ -1,90 +0,0 @@
// ============================================================
// هوک‌های 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,
});
}
+7 -47
View File
@@ -19,38 +19,23 @@ import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
ConfirmedReceipt, ConfirmedReceipt,
DashboardFilterOptions,
DailyControlData,
DeadStockItem,
DrilldownData,
FlowDay,
InTransitRow,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
Material, Material,
PendingArrival,
Project, Project,
Quote, Quote,
QuoteStats,
StandardConversion, StandardConversion,
Store, Store,
Supplier, Supplier,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferReceiveIn,
ProjectTransferUpdateIn,
TransactionType, TransactionType,
Warehouse, Warehouse,
WorkloadProject, ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferUpdateIn,
ProjectTransferReceiveIn,
} from "./types"; } from "./types";
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = {
material_id?: string | null;
brand_id?: string | null;
specs_hash?: string | null;
};
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
@@ -1902,12 +1887,8 @@ export const apiV26 = {
pm: { pm: {
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => { onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
const qs = new URLSearchParams( getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
).toString();
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
},
confirmReceipt: async (body: { confirmReceipt: async (body: {
item_decision_id: string; received_qty: number; item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[]; allocations: { warehouse_id: string; quantity: number }[];
@@ -1918,27 +1899,6 @@ export const apiV26 = {
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"), getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
}, },
// ---------- داشبورد MVP (v2.8) — تجمیع‌های سمت سرور، فقط‌خواندنی ----------
// نقش/پروژه سمت بک‌اند از JWT (یا هدرهای dev) حل می‌شود؛ PM به پروژه‌های
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را می‌بیند.
dashboard: {
filters: async (): Promise<DashboardFilterOptions> =>
getData<DashboardFilterOptions>("/dashboard/filters"),
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ 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: { catalog: {
+21
View File
@@ -115,6 +115,27 @@ export function isoToJalaliStr(iso: string | null | undefined): string {
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`; return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
} }
// format an ISO string to "۱۴۰۳/۰۵/۱۲ ۱۴:۳۰"
export function isoToJalaliDateTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${toFa(hh)}:${toFa(mm)}`;
}
// format time only: "۱۴:۳۰"
export function isoToTimeStr(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${toFa(hh)}:${toFa(mm)}`;
}
// format with month name: "۱۸ مرداد ۱۴۰۴" // format with month name: "۱۸ مرداد ۱۴۰۴"
export function isoToJalaliLong(iso: string | null | undefined): string { export function isoToJalaliLong(iso: string | null | undefined): string {
if (!iso) return ""; if (!iso) return "";
+2 -133
View File
@@ -172,6 +172,7 @@ export interface InventoryTransaction {
performed_by?: string | null; performed_by?: string | null;
delivered_to?: string | null; delivered_to?: string | null;
transaction_date: string; transaction_date: string;
created_at?: string;
note?: string | null; note?: string | null;
material?: { id: string; name: string } | null; material?: { id: string; name: string } | null;
brand?: { id: string; name: string } | null; brand?: { id: string; name: string } | null;
@@ -195,12 +196,6 @@ export interface PendingArrival {
state: "on_the_way" | "partially_received" | "arrived"; state: "on_the_way" | "partially_received" | "arrived";
} }
// v2.6.1 — برند/مشخصه‌های فrozen قلم (اضافه‌شده به /pm/on-the-way برای داشبورد)
export interface InTransitRow extends PendingArrival {
brand: { id: string; name: string } | null;
specs: Record<string, string>;
}
export interface ConfirmedReceipt { export interface ConfirmedReceipt {
receipt_id: string; receipt_id: string;
quote_id: string; quote_id: string;
@@ -214,133 +209,6 @@ export interface ConfirmedReceipt {
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
} }
// ============================================================
// v2.8 — داشبورد MVP (انبار + بازرگانی)
// ============================================================
// — فیلتر سراسری «چه چیزی» —
export interface FilterSpecOption {
hash: string;
specs: Record<string, string>;
}
export interface FilterMaterialOption {
id: string;
name: string;
unit: { id: number; code: string; name_fa: string | null } | null;
brands: { id: string; name: string }[];
specs: FilterSpecOption[];
}
export interface DashboardFilterOptions {
materials: FilterMaterialOption[];
}
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
export type QtyByUnit = Record<string, number>;
export interface DrilldownWarehouse {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
}
export interface DrilldownProject {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
warehouses: DrilldownWarehouse[];
}
export interface DrilldownMaterial {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
projects: DrilldownProject[];
}
export interface DrilldownData {
units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number };
materials: DrilldownMaterial[];
}
// — جدول موجودی راکد —
export interface DeadStockItem {
inventory_id: string;
material: string;
brand: string | null;
specs: Record<string, string>;
warehouse: string;
project: string;
quantity: number;
unit: { code: string; name_fa: string | null };
last_activity: string | null;
idle_days: number | null;
}
// — سری زمانی جریان روزانه —
export 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 { export interface ProjectTransfer {
id: string; id: string;
from_project: { id: string; name: string }; from_project: { id: string; name: string };
@@ -388,3 +256,4 @@ export interface ProjectTransferReceiveIn {
received_at?: string | null; received_at?: string | null;
note?: string | null; note?: string | null;
} }
+1 -10
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react"; import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import { import {
useMaterials, useMaterials,
useProjects, useProjects,
@@ -94,15 +94,6 @@ export function DashboardPage() {
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
{/* داشبورد تحلیلی — دکمه‌ی مستقل، هم‌سبک با «پروژه‌ها» و «ضرایب تبدیل» */}
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<ChartColumnBig className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>داشبورد</span>
</Link>
<Link to="/suppliers" className="btn-line-file no-underline"> <Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" /> <Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها تأمین‌کننده‌ها
@@ -1,99 +0,0 @@
// ============================================================
// داشبورد 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>
);
}
@@ -81,7 +81,35 @@ export function ChangeInventoryPage() {
const [matTxt, setMatTxt] = useState(""); const [matTxt, setMatTxt] = useState("");
const [brandTxt, setBrandTxt] = useState(""); const [brandTxt, setBrandTxt] = useState("");
const [qty, setQty] = useState(""); const [qty, setQty] = useState("");
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString()); const [sectionDates, setSectionDates] = useState<Record<FormType, string>>(() => {
const now = new Date().toISOString();
return {
PURCHASE: now,
WITHDRAWAL: now,
PROJECT_TRANSFER: now,
RETURN: now,
TRANSFER: now,
TRANSFER_IN: now,
TRANSFER_OUT: now,
DAMAGE: now,
WASTE: now,
LOSS: now,
FOUND: now,
STOCK_COUNT: now,
};
});
const setSectionDate = (t: FormType, val: string | null) => {
setSectionDates((prev) => ({ ...prev, [t]: val ?? "" }));
};
const isSectionDateValid = (t: FormType): boolean => {
const raw = sectionDates[t];
if (!raw || raw.trim() === "") return false;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() <= Date.now() + 60000;
};
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState(""); const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]); const [rows, setRows] = useState<RowDraft[]>([]);
@@ -171,10 +199,13 @@ export function ChangeInventoryPage() {
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null; const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null; const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
const toServerIso = () => { const toServerIso = (t: FormType) => {
try { try {
const d = new Date(dateInput); const raw = sectionDates[t];
if (!Number.isNaN(d.getTime())) return d.toISOString(); if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
} catch { /* نادیده */ } } catch { /* نادیده */ }
return new Date().toISOString(); return new Date().toISOString();
}; };
@@ -433,13 +464,15 @@ export function ChangeInventoryPage() {
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: unitId, specs, note, quantity: n, unit_id: unitId, specs, note,
transaction_date: toServerIso(), transaction_date: toServerIso("PURCHASE"),
}; };
await postOne(op); await postOne(op);
toast.success("ثبت شد ✓"); toast.success("ثبت شد ✓");
notifyDataChanged("inventory"); notifyDataChanged("inventory");
const pIso = toServerIso("PURCHASE");
const pTime = new Date(pIso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
setSessionLog((prev) => [ setSessionLog((prev) => [
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: new Date().toTimeString().slice(0, 5), whName: whName(warehouses, whId) }, { material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: pTime, whName: whName(warehouses, whId) },
...prev, ...prev,
]); ]);
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا // حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
@@ -498,7 +531,7 @@ export function ChangeInventoryPage() {
unit_id: w.unit?.id ?? null, unit_id: w.unit?.id ?? null,
specs: w.specs || {}, specs: w.specs || {},
note: buildReturnNote(retNote[w.id], w.id), note: buildReturnNote(retNote[w.id], w.id),
transaction_date: new Date().toISOString(), transaction_date: toServerIso("RETURN"),
}); });
okCount++; okCount++;
setRetDone((prev) => new Set(prev).add(w.id)); setRetDone((prev) => new Set(prev).add(w.id));
@@ -550,7 +583,7 @@ export function ChangeInventoryPage() {
quantity: parseNum(ptQty[r.id]), quantity: parseNum(ptQty[r.id]),
specs: r.specs || {}, specs: r.specs || {},
note, note,
transfer_date: new Date().toISOString(), transfer_date: toServerIso("PROJECT_TRANSFER"),
}, user?.project_id); }, user?.project_id);
ok++; ok++;
setPtSucceeded((prev) => new Set(prev).add(r.id)); setPtSucceeded((prev) => new Set(prev).add(r.id));
@@ -586,7 +619,7 @@ export function ChangeInventoryPage() {
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
if (t === "STOCK_COUNT") { if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso("STOCK_COUNT") };
} }
return { return {
transaction_type: t, transaction_type: t,
@@ -598,7 +631,7 @@ export function ChangeInventoryPage() {
specs: rec.specs || {}, specs: rec.specs || {},
note: r.note, note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined, delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(), transaction_date: toServerIso(t),
}; };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
@@ -613,7 +646,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null; if (!(n > 0)) return null;
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso("TRANSFER") };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -629,7 +662,7 @@ export function ChangeInventoryPage() {
if (!item) return null; if (!item) return null;
if (n > item.remaining) return null; if (n > item.remaining) return null;
const t = item.tx; const t = item.tx;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date().toISOString() }; return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso("FOUND") };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است"); if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
@@ -652,7 +685,7 @@ export function ChangeInventoryPage() {
brand_id: rec.brand?.id || null, brand_id: rec.brand?.id || null,
physical_qty: n, physical_qty: n,
note: r.note, note: r.note,
transaction_date: toServerIso(), transaction_date: toServerIso("STOCK_COUNT"),
}); });
confirm.push({ confirm.push({
material: rec.material.name, material: rec.material.name,
@@ -686,6 +719,13 @@ export function ChangeInventoryPage() {
}; };
const doSubmit = () => { const doSubmit = () => {
const rawDate = sectionDates[type];
if (!rawDate || !rawDate.trim() || Number.isNaN(new Date(rawDate).getTime())) {
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است.`);
}
if (new Date(rawDate).getTime() > Date.now() + 60000) {
return toast.error("امکان ثبت تراکنش در تاریخ یا زمان آینده وجود ندارد.");
}
try { try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch(); else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch();
@@ -700,6 +740,7 @@ export function ChangeInventoryPage() {
}; };
const canSubmit = (): boolean => { const canSubmit = (): boolean => {
if (!isSectionDateValid(type)) return false;
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0); return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0);
} }
@@ -945,7 +986,31 @@ export function ChangeInventoryPage() {
</div> </div>
{type === "WITHDRAWAL" && ( {type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-3">
<div className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
</label>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…"
value={deliveredTo}
onChange={(e) => setDeliveredTo(e.target.value)}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت خروج کالا <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.WITHDRAWAL}
onChange={(iso) => setSectionDate("WITHDRAWAL", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input <input
@@ -960,55 +1025,102 @@ export function ChangeInventoryPage() {
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} {data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist> </datalist>
</div> </div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS") && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted"> <label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span> {type === "DAMAGE" ? "تاریخ و ساعت وقوع خرابی" : type === "WASTE" ? "تاریخ و ساعت ثبت ضایعات" : "تاریخ و ساعت ثبت اتلاف"} <span className="text-rose-500 font-bold">*</span>
</label> </label>
<JalaliDatePicker
value={sectionDates[type]}
onChange={(iso) => setSectionDate(type, iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input <input
type="text" type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…" placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={deliveredTo} value={matTxt}
onChange={(e) => setDeliveredTo(e.target.value)} onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/> />
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div> </div>
</div> </div>
)} )}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && ( {type === "STOCK_COUNT" && (
<div className="field"> <div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انجام انبارگردانی / شمارش <span className="text-rose-500 font-bold">*</span>
list="dl-mat" </label>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" <JalaliDatePicker
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" value={sectionDates.STOCK_COUNT}
value={matTxt} onChange={(iso) => setSectionDate("STOCK_COUNT", iso)}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
<datalist id="dl-mat"> maxDate={new Date()}
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} />
</datalist> </div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div> </div>
)} )}
</div> </div>
)} )}
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */} {/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
{isTransfer && ( {isTransfer && (
<div className="field mb-3"> <div className="grid gap-3 sm:grid-cols-2 mb-3">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انتقال بین انبارها <span className="text-rose-500 font-bold">*</span>
list="dl-mat2" </label>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" <JalaliDatePicker
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" value={sectionDates.TRANSFER}
value={matTxt} onChange={(iso) => setSectionDate("TRANSFER", iso)}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
<datalist id="dl-mat2"> maxDate={new Date()}
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} />
</datalist> </div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat2"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat2">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div> </div>
)} )}
@@ -1073,12 +1185,15 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label> <label className="text-xs font-semibold text-muted">
تاریخ و زمان (شمسی) <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker <JalaliDatePicker
value={dateInput} value={sectionDates.PURCHASE}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())} onChange={(iso) => setSectionDate("PURCHASE", iso)}
placeholder="۱۴۰۳/۰۵/۱۲" placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime withTime
maxDate={new Date()}
/> />
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
@@ -1097,18 +1212,32 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */} {/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */}
{type === "PROJECT_TRANSFER" && ( {type === "PROJECT_TRANSFER" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="field"> <div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-semibold text-muted">یادداشت بار (اختیاری — برای همه اقلام)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انتقال بین کارگاهی <span className="text-rose-500 font-bold">*</span>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" </label>
placeholder="توضیحات بار ارسالی، شماره بارنامه، راننده…" <JalaliDatePicker
value={note} value={sectionDates.PROJECT_TRANSFER}
onChange={(e) => setNote(e.target.value)} onChange={(iso) => setSectionDate("PROJECT_TRANSFER", iso)}
/> placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">یادداشت بار (اختیاری — برای همه اقلام)</label>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="توضیحات بار ارسالی، شماره بارنامه، راننده…"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1135,19 +1264,33 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* ---------- برگشت: فیلتر بازه + لیست برداشت‌ها ---------- */} {/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشت‌ها ---------- */}
{type === "RETURN" && ( {type === "RETURN" && (
<div className="space-y-3"> <div className="space-y-3">
<div> <div className="grid gap-3 sm:grid-cols-2">
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز</label> <div className="field">
<ReturnFilterBar <label className="text-xs font-semibold text-muted">
preset={retPreset} تاریخ و ساعت ثبت برگشت به انبار <span className="text-rose-500 font-bold">*</span>
onPreset={(p) => setRetPreset(p)} </label>
customFrom={retCustomFrom} <JalaliDatePicker
customTo={retCustomTo} value={sectionDates.RETURN}
onCustomFrom={(iso) => setRetCustomFrom(iso)} onChange={(iso) => setSectionDate("RETURN", iso)}
onCustomTo={(iso) => setRetCustomTo(iso)} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز برداشت‌ها (فیلتر جستجو)</label>
<ReturnFilterBar
preset={retPreset}
onPreset={(p) => setRetPreset(p)}
customFrom={retCustomFrom}
customTo={retCustomTo}
onCustomFrom={(iso) => setRetCustomFrom(iso)}
onCustomTo={(iso) => setRetCustomTo(iso)}
/>
</div>
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1208,14 +1351,28 @@ export function ChangeInventoryPage() {
)} )}
{type === "FOUND" && ( {type === "FOUND" && (
<FoundTable <div className="space-y-3">
items={pendingLosses} <div className="field max-w-sm">
rows={rows} <label className="text-xs font-semibold text-muted">
setRows={setRows} تاریخ و ساعت پیدا شدن کالا <span className="text-rose-500 font-bold">*</span>
unitNameFn={(id) => unitName(data.lookup.units, id)} </label>
emptyMsg={foundEmptyMsg} <JalaliDatePicker
loading={foundLoading && !pendingLosses.length} value={sectionDates.FOUND}
/> onChange={(iso) => setSectionDate("FOUND", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<FoundTable
items={pendingLosses}
rows={rows}
setRows={setRows}
unitNameFn={(id) => unitName(data.lookup.units, id)}
emptyMsg={foundEmptyMsg}
loading={foundLoading && !pendingLosses.length}
/>
</div>
)} )}
{/* دکمه ثبت — دسکتاپ */} {/* دکمه ثبت — دسکتاپ */}
@@ -1533,7 +1690,7 @@ export function ChangeInventoryPage() {
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: type === "RETURN" : type === "RETURN"
? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند." ? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`} : `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || new Date()))}`}
</div> </div>
</PageLayout> </PageLayout>
@@ -140,7 +140,7 @@ export function DailyControlPage() {
if (t.transaction_date > g.last) g.last = t.transaction_date; if (t.transaction_date > g.last) g.last = t.transaction_date;
} }
}); });
return [...gmap.values()]; return [...gmap.values()].sort((a, b) => new Date(b.last).getTime() - new Date(a.last).getTime());
}, [withdrawals]); }, [withdrawals]);
const isToday = sel === todayKey(); const isToday = sel === todayKey();
+199 -25
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom"; import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react"; import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown, ChevronUp, Clock } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
@@ -10,8 +10,24 @@ import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } from "../../lib/jalali";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
const TX_TYPE_OPTIONS: { type: string; label: string }[] = [
{ type: "", label: "همه عملیات‌ها" },
{ type: "PURCHASE", label: "موجودی اولیه / خرید" },
{ type: "WITHDRAWAL", label: "خروج / مصرف" },
{ type: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی" },
{ type: "RETURN", label: "برگشت به انبار" },
{ type: "TRANSFER_OUT", label: "انتقال بین انبارها (خروجی)" },
{ type: "TRANSFER_IN", label: "انتقال بین انبارها (ورودی)" },
{ type: "DAMAGE", label: "خرابی" },
{ type: "WASTE", label: "ضایعات" },
{ type: "LOSS", label: "مفقودی" },
{ type: "FOUND", label: "پیدا شده" },
{ type: "STOCK_COUNT", label: "انبارگردانی / شمارش" },
];
// رنگ‌بندی معنایی تراکنش‌ها // رنگ‌بندی معنایی تراکنش‌ها
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = { const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
@@ -58,10 +74,20 @@ export function InventoryPage() {
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited"; const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{
warehouse: string;
material: string;
project: string;
transactionType: string;
dateFrom: string | null;
dateTo: string | null;
}>({
warehouse: params.get("warehouse") || "", warehouse: params.get("warehouse") || "",
material: "", material: "",
project: "", project: "",
transactionType: "",
dateFrom: null,
dateTo: null,
}); });
const [sort, setSort] = useState<"asc" | "desc" | "">(""); const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false); const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -77,14 +103,40 @@ export function InventoryPage() {
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]); const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
// فیلتر تراکنش‌ها بر اساس نوع و بازه زمانی روزانه (۰۰:۰۰:۰۰ تا ۲۳:۵۹:۵۹)
const filteredTransactions = useMemo(() => {
let list = transactions;
if (filters.transactionType) {
list = list.filter((t) => t.transaction_type === filters.transactionType);
}
if (filters.dateFrom) {
const dFrom = new Date(filters.dateFrom);
const startOfDay = new Date(dFrom.getFullYear(), dFrom.getMonth(), dFrom.getDate(), 0, 0, 0, 0);
list = list.filter((t) => new Date(t.transaction_date).getTime() >= startOfDay.getTime());
}
if (filters.dateTo) {
const dTo = new Date(filters.dateTo);
const endOfDay = new Date(dTo.getFullYear(), dTo.getMonth(), dTo.getDate(), 23, 59, 59, 999);
list = list.filter((t) => new Date(t.transaction_date).getTime() <= endOfDay.getTime());
}
return list;
}, [transactions, filters.transactionType, filters.dateFrom, filters.dateTo]);
const hasTxFilter = !!(filters.transactionType || filters.dateFrom || filters.dateTo);
const groups = useMemo<Group[]>(() => { const groups = useMemo<Group[]>(() => {
const gmap = new Map<string, Group>(); const gmap = new Map<string, Group>();
const order: string[] = []; const order: string[] = [];
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
inventory.forEach((r) => { inventory.forEach((r) => {
const whId = r.warehouse?.id; const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return; if (!whId || !visibleIds.has(whId)) return;
if (filters.warehouse && whId !== filters.warehouse) return; if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return; if (filters.material && !r.material.name.includes(filters.material.trim())) return;
if (activeInvIds && !activeInvIds.has(r.id)) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk); let g = gmap.get(gk);
if (!g) { if (!g) {
@@ -104,7 +156,6 @@ export function InventoryPage() {
order.push(gk); order.push(gk);
} }
g.qty += r.quantity_number || 0; g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r); g.recs.push(r);
if (r.brand) { if (r.brand) {
if (!(r.brand.id in g.brands)) { if (!(r.brand.id in g.brands)) {
@@ -114,26 +165,61 @@ export function InventoryPage() {
g.brands[r.brand.id] += r.quantity_number || 0; g.brands[r.brand.id] += r.quantity_number || 0;
} }
}); });
// استخراج آخرین تاریخ تراکنش واقعی کاربر برای هر گروه و انتساب به updatedAt
order.forEach((k) => {
const g = gmap.get(k);
if (!g) return;
const groupAllTxs = transactions.filter((t) => g.recs.some((r) => r.id === t.inventory_id));
if (groupAllTxs.length > 0) {
const latestTx = groupAllTxs.reduce((latest, t) => {
return new Date(t.transaction_date).getTime() > new Date(latest.transaction_date).getTime() ? t : latest;
}, groupAllTxs[0]);
g.updatedAt = latestTx.transaction_date;
}
});
const out = order.map((k) => gmap.get(k)!); const out = order.map((k) => gmap.get(k)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.qty); if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty); else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return out; return out;
}, [inventory, visibleIds, filters, sort]); }, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
const groupTxs = (g: Group) => const groupTxs = (g: Group) =>
transactions filteredTransactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id)) .filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice() .slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .sort((a, b) => {
const da = new Date(a.transaction_date).getTime();
const db = new Date(b.transaction_date).getTime();
if (da !== db) return da - db;
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
return ca - cb;
});
const brandTxs = (g: Group, bid: string) => const brandTxs = (g: Group, bid: string) =>
transactions filteredTransactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id)) .filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice() .slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .sort((a, b) => {
const da = new Date(a.transaction_date).getTime();
const db = new Date(b.transaction_date).getTime();
if (da !== db) return da - db;
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
return ca - cb;
});
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project)); const hasFilter = !!(
filters.warehouse ||
(isAdmin && filters.project) ||
filters.material ||
filters.transactionType ||
filters.dateFrom ||
filters.dateTo
);
if (loading) return <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user) { if (!user) {
@@ -285,13 +371,72 @@ export function InventoryPage() {
</div> </div>
)} )}
{/* نوع عملیات / بخش */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">نوع عملیات / بخش:</div>
<div className="flex flex-wrap gap-1.5">
{TX_TYPE_OPTIONS.map((opt) => (
<button
key={opt.type}
type="button"
onClick={() => setFilters((f) => ({ ...f, transactionType: f.transactionType === opt.type ? "" : opt.type }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.transactionType === opt.type
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* فیلتر بازه تاریخ یا روز مشخص */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-bold text-muted">فیلتر بازه روزانه (بازه کامل ۰۰:۰۰ تا ۲۳:۵۹):</span>
<button
type="button"
onClick={() => {
const todayIso = new Date().toISOString().slice(0, 10);
setFilters((f) => ({ ...f, dateFrom: todayIso, dateTo: todayIso }));
}}
className="text-[11px] text-accent font-semibold hover:underline cursor-pointer"
>
امروز
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 max-w-md">
<div>
<label className="mb-0.5 block text-[10.5px] text-muted">از تاریخ (یا روز مشخص):</label>
<JalaliDatePicker
value={filters.dateFrom}
onChange={(val) => setFilters((f) => ({ ...f, dateFrom: val, dateTo: f.dateTo || val }))}
placeholder="از تاریخ…"
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-0.5 block text-[10.5px] text-muted">تا تاریخ:</label>
<JalaliDatePicker
value={filters.dateTo}
onChange={(val) => setFilters((f) => ({ ...f, dateTo: val }))}
placeholder="تا تاریخ…"
maxDate={new Date()}
/>
</div>
</div>
</div>
{/* دکمه پاک کردن فیلترها */} {/* دکمه پاک کردن فیلترها */}
{hasFilter && ( {hasFilter && (
<div className="pt-1 flex items-center justify-between"> <div className="pt-1 flex items-center justify-between">
<button <button
type="button" type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1" className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
onClick={() => setFilters({ warehouse: "", material: "", project: "" })} onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
پاک کردن فیلترها پاک کردن فیلترها
@@ -363,7 +508,6 @@ export function InventoryPage() {
<tr className="border-b border-line bg-bg/40 text-right text-muted"> <tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" /> <th className="w-8 py-2.5" />
<th className="py-2.5 pe-3 font-semibold">متریال</th> <th className="py-2.5 pe-3 font-semibold">متریال</th>
<th className="py-2.5 pe-3 font-semibold">دسته‌بندی</th>
<th className="py-2.5 pe-3 font-semibold">انبار</th> <th className="py-2.5 pe-3 font-semibold">انبار</th>
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th> <th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
<th className="py-2.5 pe-3 font-semibold">تعداد</th> <th className="py-2.5 pe-3 font-semibold">تعداد</th>
@@ -515,8 +659,9 @@ function InventoryMobileCard({
<span className="text-muted/60">—</span> <span className="text-muted/60">—</span>
)} )}
</div> </div>
<span className="shrink-0 tabular-nums"> <span className="shrink-0 tabular-nums flex items-center gap-1">
{isoToJalaliStr(g.updatedAt)} <Clock className="w-3 h-3 text-muted/70" />
<span>{isoToJalaliDateTime(g.updatedAt)}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -554,13 +699,23 @@ function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, ti
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"} {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td> </td>
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td> <td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3">{unit}</td> <td className="py-2.5 pe-3 font-medium text-ink/80">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td> <td className="py-2.5 pe-3 text-muted tabular-nums">
<td className="py-2.5 text-muted">🕘</td> <div className="font-semibold text-ink">{isoToJalaliStr(g.updatedAt)}</div>
<div className="text-[10.5px] text-muted flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3 text-muted/70 shrink-0" />
<span>{isoToTimeStr(g.updatedAt)}</span>
</div>
</td>
<td className="py-2.5 text-center text-muted">
<span className="p-1 inline-flex items-center justify-center rounded hover:text-accent">
{isExp ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</span>
</td>
</tr> </tr>
{isExp && ( {isExp && (
<tr className="border-b border-line/40 bg-accent-bg/10"> <tr className="border-b border-line/40 bg-accent-bg/10">
<td colSpan={9} className="p-3.5">{timeline}</td> <td colSpan={8} className="p-3.5">{timeline}</td>
</tr> </tr>
)} )}
</> </>
@@ -592,14 +747,29 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setSelTx(sel ? null : t.id)} onClick={() => setSelTx(sel ? null : t.id)}
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`} className={`flex min-w-[135px] flex-col gap-1 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
style={{ borderTop: `3px solid ${meta.color}` }} style={{ borderTop: `3px solid ${meta.color}` }}
> >
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span> <div className="flex items-center justify-between gap-1">
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}> <span className="text-[11px] font-bold" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit} {meta.icon} {meta.label}
</span>
</div>
<span className="text-[12.5px] font-extrabold tabular-nums" style={{ color: meta.color }}>
{t.quantity_number > 0 ? "+" : ""}{faNum(t.quantity_number)} {unit}
</span> </span>
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span> <div className="flex items-center justify-between gap-1 text-[10px] text-muted border-t border-line/40 pt-1">
<span>مانده: <b className="tabular-nums font-semibold">{faNum(bal)}</b></span>
</div>
<div className="flex items-center gap-1 text-[9.5px] text-muted/90 tabular-nums">
<Clock className="w-2.5 h-2.5 text-muted shrink-0" />
<span>{isoToJalaliDateTime(t.transaction_date)}</span>
</div>
{t.delivered_to && (
<div className="text-[9.5px] text-ink/80 font-medium truncate max-w-[125px]" title={t.delivered_to}>
تحویل: {t.delivered_to}
</div>
)}
</button> </button>
); );
}); });
@@ -623,7 +793,11 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}> <div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b> <b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted"> <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span> <span>زمان وقوع: <b className="text-ink font-bold">{isoToJalaliDateTime(t.transaction_date)}</b></span>
{t.delivered_to && <span>تحویل به: <b className="text-ink font-bold">{t.delivered_to}</b></span>}
{t.created_at && (
<span>زمان ثبت در سیستم: <b className="text-ink/80">{isoToJalaliDateTime(t.created_at)}</b></span>
)}
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? ( {t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<> <>
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span> <span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
@@ -212,6 +212,9 @@ export function OrganizationPage() {
{users.map((u) => { {users.map((u) => {
const isAdmin = u.role === "admin"; const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm); const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
return ( return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30"> <tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
@@ -320,7 +323,7 @@ export function OrganizationPage() {
<td className="py-2.5"> <td className="py-2.5">
{isAdmin ? ( {isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span> <span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : hasPanel ? ( ) : canAssignProjects ? (
<div className="w-72"> <div className="w-72">
<ProjectMultiSelect <ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))} options={projects.map((p) => ({ id: p.id, name: p.name }))}
-63
View File
@@ -1,63 +0,0 @@
// ============================================================
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
// متریال → برند → مشخصات فنی
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود.
// ============================================================
import { create } from "zustand";
export interface DashboardFilterState {
materialId: string | null; // null = همه‌ی متریال‌ها
brandId: string | null;
specsHash: string | null;
// برچسب‌های انتخاب‌شده برای نمایش چیپ فعال
materialLabel: string | null;
brandLabel: string | null;
specsLabel: string | null;
setMaterial: (id: string | null, label: string | null) => void;
setBrand: (id: string | null, label: string | null) => void;
setSpecs: (hash: string | null, label: string | null) => void;
reset: () => void;
}
const INITIAL = {
materialId: null,
brandId: null,
specsHash: null,
materialLabel: null,
brandLabel: null,
specsLabel: null,
};
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
...INITIAL,
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند (متریال → برند → مشخصات)
setMaterial: (id, label) =>
set({
materialId: id,
materialLabel: label,
brandId: null,
brandLabel: null,
specsHash: null,
specsLabel: null,
}),
setBrand: (id, label) =>
set({
brandId: id,
brandLabel: label,
specsHash: null,
specsLabel: null,
}),
setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }),
reset: () => set({ ...INITIAL }),
}));
// پارامترهای آماده برای endpointهای داشبورد
export function dashboardFilterParams(s: DashboardFilterState) {
return {
material_id: s.materialId,
brand_id: s.brandId,
specs_hash: s.specsHash,
};
}
+35 -67
View File
@@ -1,91 +1,59 @@
# PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت # PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت (Aramesh PM)
اپلیکیشن موبایل‌محور و تحت وب برای مدیریت جامع پروژه‌های عمرانی و ساخت: مدیریت سلسله‌مراتب فعالیت‌ها (WBS)، زمان‌بندی و گانت چارت، مسیر بحرانی (CPM)، سیستم پیشرفته کنترل کیفیت (QC)، مدیریت مصالح و انبار (با اتصال به ERP) و داشبورد عملیاتی روزانه. پلتفرم تخصصی مدیریت پروژه‌های عمرانی، کنترل کیفیت و زمان‌بندی کارگاهی که به صورت وندور شده و یکپارچه در کنار سامانه ERP قرار دارد.
--- ---
## دسترسی سریع ## 🏗️ نحوه استقرار و دسترسی در سامانه ERP
| سامانه | آدرس شبکه محلی | آدرس محلی سیستم | پلتفرم PM در استک یکپارچه پروژه از دو سرویس داکر تشکیل شده است:
|---|---|---| * **`pm-backend` (`pm-api`):** وب‌سرویس پایتون مبتنی بر FastAPI روی شبکه داخلی استک.
| **PM (وب + نمای موبایل)** | http://192.168.1.103:8083 | http://localhost:8083 | * **`pm-frontend` (`pm-frontend`):** اپلیکیشن کلاینت React با سرور داخلی Nginx که از طریق ریورس‌پروکسی Traefik زیر مسیر `/pm` قرار گرفته است.
| **PM API Health** | http://192.168.1.103:8083/api/v1/health | http://localhost:8083/api/v1/health |
**ورود پیش‌فرض:** `admin` / `pm123456` (قابل تغییر از طریق فایل `.env`) ### راه‌های دسترسی:
1. **از داخل رابط کاربری ERP:** با ورود به حساب کاربری (نقش‌های مجاز یا پنل PM) و انتخاب منوی **«مدیریت پروژه (PM)»** در آدرس `/panel/pm` (به صورت آی‌فریم روان با سشن یکپارچه).
2. **دسترسی مستقیم مرورگر:** در آدرس `http://localhost/pm` (یا دامنه عملیاتی با پسوند `/pm/`).
--- ---
## معماری سامانه ## 🗄️ پایگاه داده و احراز هویت مشترک
``` * **پایگاه‌داده مشترک:** سرویس PM مستقیماً به همان دیتابیس `procurement` در کانتینر Postgres متصل می‌شود. جداول اختصاصی PM (`pm_projects`, `pm_tasks`, `pm_qc_*`, `pm_defects`, ...) در زمان بوت اولیه به صورت خودکار و بدون تداخل با جداول تدارکات ایجاد می‌شوند (`schema_sync.py`).
PM Frontend (React 19, Tailwind CSS, تقویم شمسی, نمای موبایل و دسکتاپ) * **احراز هویت یکپارچه (SSO):** ورود با همان حساب کاربری Casdoor انجام شده و سطح دسترسی پنل PM (`user_panels.pm`) کنترل می‌شود.
│ /api/v1
▼
PM Backend (FastAPI, Python 3.12, SQLAlchemy 2.0 Async)
│
├── Projects & Tasks Engine (سلسله‌مراتب نامحدود WBS، زمان‌بندی CPM، گانت)
├── QC Module (قالب‌های سراسری، تعریف‌های مستقل تسک، چک‌لیست، ثبت عیوب)
├── Materials & Resources (تخصیص نیروی انسانی، ماشین‌آلات، مصالح و رزرو انبار)
├── ERP Integration (استعلام موجودی کاتالوگ و انبارهای پروژه)
└── PM Postgres (ذخیره‌سازی بومی داده‌ها با پشتیبانی از JSONB و UUID)
```
--- ---
## مالکیت داده ## ⚙️ قابلیت‌های اصلی پلتفرم PM
| حوزه داده | متولی و مالک | توضیحات | ### ۱. مدیریت زمان‌بندی و شکست کار (WBS & CPM Engine)
|---|---|---| * ساختار شکست کار با عمق نامحدود (فاز، بخش، فعالیت، زیرفعالیت).
| پروژه‌ها، فعالیت‌ها، وابستگی‌ها، مایلستون‌ها | **PM Platform** | مدیریت کامل در دیتابیس بومی PM | * روابط وابستگی چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) به همراه زمان وقفه (Lag).
| چک‌لیست‌ها و کنترل کیفیت (QC) | **PM Platform** | پیش از اجرا، حین اجرا و پس از اجرا | * محاسبه خودکار مسیر بحرانی (Critical Path Method) و شناوری کل فعالیت‌ها.
| ثبت عیوب و اقدامات اصلاحی | **PM Platform** | الصاق تصویر، شدت عیب، پیگیری رفع | * گانت چارت شمسی و تعریف مایلستون‌های کلیدی پروژه.
| کاتالوگ مصالح و موجودی واقعی انبار | **ERP** | استعلام برخط موجودی از طریق API |
| رزرو مصالح و درخواست‌های خرید | **PM Platform** | رزرو نرم بدون دستکاری انبار تا زمان تأیید | ### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC Module)
* **قالب‌های عمومی بازرسی (QC Templates):** تعریف چک‌لیست‌های استاندارد کیفیت برای انواع فعالیت‌های ساختمانی.
* **مراحل ارزیابی:**
* **پیش از اجرا (`pre`):** دروازه ورود به تسک؛ تا زمان تایید این مرحله، پیشرفت تسک نمی‌تواند بیشتر از ۰٪ شود.
* **حین اجرا (`during`):** ثبت بازرسی‌های فنی حین عملیات اجرایی.
* **پس از اجرا (`after`):** کنترل نهایی؛ تا زمان تایید تمامی سوالات الزامی، پیشرفت تسک اجازه ۱۰۰٪ شدن ندارد.
* **ثبت و پیگیری عیوب (Defects):** ثبت عیوب همراه با تصاویر، سطح بحرانی بودن و لزوم بازرسی مجدد پس از اقدامات اصلاحی.
### ۳. مدیریت منابع و اتصال به انبار کارگاه
* امکان تخصیص متریال و ثبت رزرو نرم با استعلام موجودی واقعی انبارهای متناظر در ERP.
--- ---
## قابلیت‌های کلیدی ## 🧪 اجرای آزمایشی مستقل (Standalone Dev)
### ۱. مدیریت زمان‌بندی و گانت چارت (CPM Engine)
- ایجاد پروژه‌ها با کدهای اختصاصی و تیم پروژه
- ساختار شکست کار (WBS) در عمق‌های نامحدود (فاز، بخش، فعالیت، زیرفعالیت)
- وابستگی‌های چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) همراه با روزهای تأخیر (Lag)
- محاسبه خودکار مسیر بحرانی (Critical Path) و شناوری کل فعالیت‌ها
- تعریف مایلستون‌ها و نقاط عطف کلیدی پروژه
### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC)
- **مراحل ارزیابی:** پیش از اجرا (`pre`) · حین اجرا (`during`) · پس از اجرا (`after`)
- **قوانین دروازه‌ای سرور:**
- فعالیت تا زمان تأیید الزامات پیش از اجرا اجازه شروع (پیشرفت > ۰) ندارد.
- فعالیت تا زمان پاس شدن همه الزامات الزامی اجازه رسیدن به ۱۰۰٪ ندارد.
- ثبت عیوب همراه با تصاویر و الزام به بازرسی مجدد پس از اصلاح.
### ۳. مدیریت منابع و ارتباط با ERP
- تخصیص نفرات، ماشین‌آلات و مصالح به تفکیک فعالیت
- اتصال پروژه به پروژه متناظر در ERP جهت استعلام موجودی انبارهای کارگاه
- رزرو نرم مصالح جهت جلوگیری از تداخل مصرف در بخش‌های مختلف پروژه
### ۴. تقویم و تاریخ‌های شمسی
- تقویم بومی جلالی بدون خطا و با رعایت روزهای کاری
- ثبت و نمایش روان تاریخ‌ها در رابط کاربری به زبان فارسی
---
## راه‌اندازی و اجرا
اجرای آسان با اسکریپت پاورشل یا داکر کامپوز:
در صورت تمایل به اجرای مستقل پلتفرم PM برای تست‌های ایزوله:
```powershell ```powershell
# اجرای خودکار از پوشه PM: # از درون پوشه pm:
powershell -ExecutionPolicy Bypass -File .\start.ps1 powershell -ExecutionPolicy Bypass -File .\start.ps1
``` ```
این حالت روی پورت محلی `8083` به صورت ایزوله اجرا خواهد شد.
یا مستقیماً با داکر:
```powershell
docker compose up -d --build
```
--- ---
## قرارداد API ## 📑 قرارداد و مستندات API
مستندات کامل اندپوینت‌ها: [`API_CONTRACT.md`](API_CONTRACT.md) قرارداد جامع اندپوینت‌ها و نمونه پیام‌ها در فایل [API_CONTRACT.md](API_CONTRACT.md) و راهنمای تفصیلی ساختار جداول در [DATABASE_GUIDE.md](DATABASE_GUIDE.md) مستند شده است.
+4 -23
View File
@@ -165,15 +165,13 @@ CREATE TABLE category_attributes (
); );
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ---------- -- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- Materials are global entities. name is GLOBALLY unique (uq_material_name). -- Materials are global, category-free entities. name is GLOBALLY
-- topic is a legacy display column; category_id (v2.8) is the dashboard's -- unique (uq_material_name). topic is a legacy display column no longer
-- Category→Material cascade link — backfilled from topic at API startup -- written by the app.
-- (init_db.ensure_dashboard_schema; NULL = «بدون دسته»).
CREATE TABLE materials ( CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
topic TEXT, -- LEGACY display column topic TEXT, -- LEGACY display column
category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL,
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
status material_status NOT NULL DEFAULT 'active', status material_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -483,23 +481,6 @@ CREATE INDEX idx_category_attrs_category ON category_attributes(category_id);
CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id); CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id);
CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id); CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id);
-- ============================================================================
-- MVP DASHBOARD INDEXES (v2.8 — aggregation queries in dashboard_service)
-- (Fresh installs; existing volumes get the same set idempotently via
-- init_db.ensure_dashboard_schema.)
-- ============================================================================
CREATE INDEX idx_materials_category ON materials(category_id);
CREATE INDEX idx_warehouses_project ON warehouses(project_id);
CREATE INDEX idx_inventory_tx_date ON inventory_transactions(transaction_date);
CREATE INDEX idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date);
CREATE INDEX idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL;
CREATE INDEX idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL;
CREATE INDEX idx_quotes_status ON quotes(status);
CREATE INDEX idx_item_decisions_project ON item_decisions(project_id);
CREATE INDEX idx_item_receipts_received ON item_receipts(received_at);
CREATE INDEX idx_inventory_material_brand ON inventory(material_id, brand_id);
-- ============================================================================ -- ============================================================================
-- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO) -- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO)
-- ============================================================================ -- ============================================================================
@@ -706,7 +687,7 @@ 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 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 progress DOUBLE PRECISION DEFAULT 0.0;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
(revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')) -- (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
COMMIT; COMMIT;