Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31300728b6 | ||
|
|
42fb314834 | ||
|
|
264c7cc906 | ||
|
|
707e40e840 | ||
|
|
ca2c1bb8ce |
@@ -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`.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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)")
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
Generated
-390
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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,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();
|
||||||
|
|||||||
@@ -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 }))}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user