Author SHA1 Message Date
Behrad 97bda79de9 improve gantt chart 2 2026-10-15 15:23:18 +03:30
Behrad 67fd0222c7 improve gantt chart 2026-10-15 12:55:35 +03:30
Behrad ce1421d038 Fix date validations, datepicker overflow, and QC components 2026-10-14 15:24:11 +03:30
18 changed files with 1360 additions and 1767 deletions
+313 -145
View File
@@ -1,191 +1,359 @@
# ERP & PM Integrated Platform — Dockerized
## سامانه یکپارچه تدارکات، انبارداری، مدیریت پروژه (PM) و ربات هوشمند
مجموعه نرم‌افزاری یکپارچه و خودکفا (Self-contained) شامل تمامی سرویس‌ها، ماژول‌ها و تنظیمات زیرساختی مورد نیاز برای اجرا در محیط توسعه و عملیات:
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
| سرویس | نام کانتینر | فناوری / کاربرد | پورت در دسترس |
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 (دیتابیس مشترک `procurement` + `casdoor`) | `5455:5432` |
| **minio** | `procurement-minio` | MinIO Object Storage (S3-compatible) برای فایل‌ها و پیش‌فاکتورها | `9000` (API), `9001` (کنسول) |
| **casdoor** | `procurement-casdoor` | ارائه‌دهنده متمرکز هویت سازمانی (OIDC Provider) | `8080:8000` |
| **api** | `procurement-api` | بک‌اند اصلی تدارکات و انبار (FastAPI + Asyncpg) | `8000:8000` |
| **frontend** | `procurement-frontend` | فرانت‌اند وب ERP (React 19 + Vite + Tailwind) | سرو از طریق Traefik روی پورت `80` |
| **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) |
| **postgres** | `procurement-postgres` | PostgreSQL 18 (one instance: `procurement` + `casdoor` DBs) | `5433` |
| **minio** | `procurement-minio` | MinIO object storage (S3-compatible) | `9000` (API), `9001` (console) |
| **casdoor** | `procurement-casdoor` | Casdoor (OIDC identity provider) | `8080` |
| **api** | `procurement-api` | FastAPI + SQLAlchemy (asyncpg) | `8000` |
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
---
## 🌐 معماری شبکه و هدایت ترافیک (Traefik Edge Routing)
```
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)
```text
┌────────────────────────┐
│ Browser / Client │
└───────────┬────────────┘
│ :80 / :443
▼
┌────────────────────────┐
│ 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) │
└───────────────────────┘
```
---
## 📁 ساختار پوشه‌ها و اجزای مخزن
## 📁 Layout
```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
v1.3/
├── docker-compose.yml ← all paths are relative to this folder
├── docker-compose.prod.yml ← production overlay (real domains + HTTPS via Let's Encrypt)
├── start.ps1 ← PowerShell helper (build + start + status)
├── mc.cmd ← MinIO Client helper (inspect the bucket)
├── README.md
├── sql/
│ ├── migration.sql ← اسکریپت اولیه پایه دیتابیس
│ └── 02-casdoor-db.sql ← اسکریپت راه‌اندازی دیتابیس هویت Casdoor
└── tests/ ← تست‌های یکپارچگی NLU و ویزارد استعلام
│ ├── migration.sql ← DB schema v2.6 (auto-runs on first boot)
│ └── 02-casdoor-db.sql ← creates the `casdoor` role+DB on the same instance
├── infra/
│ └── 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)
```
---
## 🔐 سیستم احراز هویت، ماتریس پنل‌ها و کنترل دسترسی (RBAC)
## 🔐 Login
سامانه از سیستم احراز هویت **Casdoor OIDC** بهره می‌برد؛ ورود به صورت In-Page بوده و توکن JWT صادر شده توسط بک‌اند با JWKS اعتبارسنجی می‌شود.
The whole system is behind a login page (`/login`). Login is **in-page**:
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.
مدل دسترسی سامانه بر پایه **پنل‌ها (`user_panels`)** بازطراحی شده است:
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only.
### ۱. لایه‌های دسترسی پنل‌ها
* **پنل تدارکات (`procurement`):**
* `full`: دسترسی کامل به کارتابل استعلام‌ها، تایید یا رد پیشنهادها، تامین‌کنندگان و قیمت‌ها.
* `limited`: فقط امکان ثبت استعلام جدید در ویزارد برای پروژه‌های منتسب به کاربر (کاربر پس از ورود مستقیماً به `/wizard` یا `/requester` هدایت می‌شود).
* **پنل انبارداری (`inventory`):**
* `full`: مشاهده موجودی همه انبارها، رهگیری «کالاهای در راه» و کارتابل تایید رسید.
* `limited`: فقط مشاهده و مدیریت انبارهای پروژه‌های تحت عضویت، همراه با دسترسی به ثبت تغییر موجودی کارگاهی (`/inventory/change`).
* **پنل مدیریت پروژه (`pm`):**
* دسترسی به مشاهده یا ویرایش تسک‌ها، چارت گانت، ثبت عیوب و فرم‌های QC در `/panel/pm`.
**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) →
② role switcher (ادمین / مدیر تدارکات / مدیر پروژه + project) → ③ session
activation by role (admin → **پنل مدیریت ادمین** at `/admin`, مدیر تدارکات →
procurement office, مدیر پروژه → their project's inventory panel). It is
controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
`DEV_LOGIN_ENABLED` compose build arg, default `true` — set it to `false`
for production builds).
### ۲. قوانین و نقش سرپرست پروژه (`supervisor`)
* کاربرانی با نقش `supervisor` تنها در صورتی مجاز به ورود به سامانه هستند که حداقل یک پروژه در جدول `user_project_members` یا `projects.pm_user_id` به آن‌ها منتسب شده باشد. در غیر این صورت، پیام عدم تخصیص پروژه نمایش داده شده و اجازه ورود نخواهند داشت.
| What | Where | Credentials |
| --- | --- | --- |
| App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
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).
---
## 🚀 راهنمای سریع راه‌اندازی محیط توسعه (Docker)
## 🌐 Working with the deployed system (server)
### پیش‌نیازها
* Docker Desktop (ویندوز) یا Docker Engine + Docker Compose Plugin (لینوکس)
* حداقل ۴ گیگابایت فضای خالی دیسک و اتصال اینترنت برای بارگیری ایمیج‌های اولیه
The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS):
### دستورات اجرا
| What | Address |
| --- | --- |
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](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
# ۱. رفتن به پوشه پروژه و ساخت/اجرای کانتینرها
cd <this-folder>
docker compose up -d --build
# ۲. بررسی وضعیت سلامت سرویس‌ها
docker compose ps
```
تمام سرویس‌ها باید وضعیت `Up` و دیتابیس وضعیت `(healthy)` را نشان دهند.
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:
| بخش | آدرس محلی | توضیحات |
|---|---|---|
| **رابط کاربری 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` |
```powershell
docker compose ps
```
All should show `Up` (postgres also `(healthy)`).
---
## 🤖 ربات هوشمند بله (`procurement-bot`)
## Verify
ربات پیام‌رسان بله به عنوان یک سرویس اختصاصی درون شبکه فعال است:
1. پیام صوتی یا متنی فرستاده شده از طریق هوش مصنوعی (DeepSeek NLU) و تبدیل صوت به متن پردازش می‌شود.
2. قلم‌ها، متریال‌ها، پروژه‌ها و تامین‌کنندگان به صورت فازی جستجو و شناسایی می‌شوند.
3. پیش‌نویس در جدول `bot_drafts` ذخیره شده و شناسه پیش‌نویس تولید می‌شود.
4. کاربر می‌تواند با زدن دکمه روی بله یا مراجعه به لینک مستقیم:
`http://localhost/wizard?draft=<UUID>`
اطلاعات استخراج‌شده را در ویزارد فرانت‌اند مرور، تکمیل و نهایی کند.
5. راهنمای تکمیلی تنظیم توکن در [bot/docs/SETUP.md](file:///c:/Users/Reza/Desktop/v1.6/bot/docs/SETUP.md) قرار دارد.
| What | Where |
| --- | --- |
| Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready |
| Metrics | http://localhost:8000/metrics |
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) |
| 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).
---
## 🔄 انتقال بین‌کارگاهی کالاها (`project_transfers`)
## MinIO (object storage for invoice / proforma files)
سامانه دارای سیستم ردیابی انتقال متریال بین کارگاه‌هاست:
* ثبت انتقال از طریق صفحه «تغییر موجودی» با نوع `انتقال بین کارگاهی`.
* وضعیت‌های انتقال: `در حال انتقال (in_transit)`، `تحویل‌شده (received)` و `لغوشده (cancelled)`.
* کارگاه مقصد از طریق زبانه «انتقالات ورودی» در صفحه «کالاهای در راه» اقلام ارسالی را دریافت و به انبارهای خود تخصیص می‌دهد.
| Item | Value |
| --- | --- |
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
| Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
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
```
---
## 🛠️ عملیات نگهداری و دیپلوی در سرور (Operations)
## Database credentials / connection
* **دیپلوی خودکار:** سرور با هر Push به برنچ `production` از طریق وب‌هوک اختصاصی به صورت خودکار بیلد و بالا می‌آید. راهنمای کامل: [docs/OPERATIONS.md](file:///c:/Users/Reza/Desktop/v1.6/docs/OPERATIONS.md).
* **پشتیبان‌گیری پایگاه داده:** سرویس `db-backup` به صورت روزانه از هر دو پایگاه داده `procurement` و `casdoor` فایل‌های `.dump` تولید و در پوشه `backups/` نگهداری می‌کند.
* **مانیتورینگ و مشاهده‌پذیری:** ابزارهای Prometheus، لاگ‌های Loki و ردیابی خطاهای GlitchTip در پوشه [monitoring/](file:///c:/Users/Reza/Desktop/v1.6/monitoring/README.md) سازمان‌دهی شده‌اند.
```text
Host: localhost
Port: 5433
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`.
```
+1 -5
View File
@@ -217,7 +217,6 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"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,
}
@@ -462,10 +461,7 @@ async def list_transactions(
InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
)
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")
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db)
# Enrich: warehouse name + material/brand/specs/unit for the UI rows.
+5 -7
View File
@@ -22,7 +22,6 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty).
import hashlib
import json
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import select
@@ -176,7 +175,6 @@ async def apply_transaction(
t = payload.transaction_type
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
tx_date = payload.transaction_date or datetime.now(timezone.utc)
# STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT":
@@ -199,7 +197,7 @@ async def apply_transaction(
recorded_qty=recorded,
physical_qty=payload.physical_qty,
performed_by=performed_by,
transaction_date=tx_date,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
@@ -240,7 +238,7 @@ async def apply_transaction(
quantity=-quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=tx_date,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(out_tx)
@@ -258,7 +256,7 @@ async def apply_transaction(
quantity=quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=tx_date,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(in_tx)
@@ -293,7 +291,7 @@ async def apply_transaction(
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=tx_date,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
@@ -323,7 +321,7 @@ async def apply_transaction(
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=tx_date,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
+105 -55
View File
@@ -1,70 +1,120 @@
# procurement_ai_agent — ایجنت هوشمند بله برای سامانه ERP
# procurement_ai_agent — Bale chatbot for the procurement ERP
بات پیام‌رسان بله (Bale Messenger Bot) که پیام‌های صوتی و متنی فارسی کاربران را دریافت کرده و با استفاده از مدل‌های هوش مصنوعی (DeepSeek NLU + Whisper STT) آن‌ها را به اقدامات و پیش‌نویس‌های استعلام در سامانه ERP تبدیل می‌کند.
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
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.
---
## 🏗️ وضعیت استقرار در استک ERP
این ایجنت **به صورت سرویس بومی (`procurement-bot`) مستقیماً درون `docker-compose.yml` ریشه پروژه** تعریف شده است و نیازی به نصب یا راه‌اندازی دستی ندارد.
* **نوع اتصال:** Long-polling از سرور بله (`tapi.bale.ai`) — بدون نیاز به باز کردن پورت خارجی، وب‌هوک یا TLS.
* **ارتباط با بک‌اند:** به صورت مستقیم و درون‌شبکه‌ای با سرویس `api` در آدرس `http://api:8000/api/v1`.
* **پایگاه داده:** استفاده از جدول `bot_drafts` در پایگاه‌داده مشترک PostgreSQL.
```text
کاربر (بله: متن یا صوت)
│ getUpdates (Long-polling)
▼
┌─────────────────────────────────┐
│ procurement_ai_agent │
│ ffmpeg → STT تبدیل صوت به متن │
│ LLM → استخراج نیت و موجودیت‌ها │
│ Fuzzy Match → انطباق با ERP │
│ ایجاد پیش‌نویس در bot_drafts │
└────────────────┬────────────────┘
│
├── ثبت نهایی از بله با دکمه تایید
└── یا باز کردن مستقیم در ویزارد وب:
/wizard?draft=<UUID>
```
you (Bale, text/voice) ┌─────────────────────────────────┐
│ getUpdates (long poll) │ procurement_ai_agent │
▼ │ ffmpeg → STT (Persian) │
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│
└───────────────┬─────────────────┘
│ HTTP + X-Actor-Id
▼
┌─────────────────────────────────┐
│ ERP api (/api/v1) │
│ quotes · suppliers · materials│
└─────────────────────────────────┘
```
---
## What it can do
## 🌟 قابلیت‌ها و کاربردها
| فعالیت | نمونه پیام (صوت یا متن) | اقدام نهایی |
| Activity | Example (voice or text) | Committed via |
|---|---|---|
| **ثبت استعلام جدید** | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| **تعریف تأمین‌کننده** | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| **تعریف متریال جدید** | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| **استعلام وضعیت** | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes` |
| **پذیرش استعلام** | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
---
### The draft → approve loop
## 🔗 حلقه پیش‌نویس و تایید (Draft-First Loop)
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM
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.
1. کاربر پیام صوتی یا متنی می‌فرستد. صوت به متن تبدیل شده و مدل هوش مصنوعی موجودیت‌ها را استخراج می‌کند.
2. اسامی تامین‌کننده، متریال، پروژه و واحدها با کاتالوگ ERP تطبیق داده می‌شوند.
3. کارت مرور پیش‌نویس همراه با فیلدهای مشخص‌شده و اقلام خالی («چه چیزهایی ناقص است») برای کاربر ارسال می‌شود.
4. **دو روش برای تکمیل و ثبت نهایی وجود دارد:**
* **از طریق چت بله:** با ارسال پیام‌های اصلاحی و در نهایت لمس دکمه «✅ ثبت نهایی».
* **از طریق پنل وب ویزارد:** با کلیک روی لینک مستقیم `/wizard?draft=<UUID>` که اطلاعات به صورت خودکار در فرم ۵ مرحله‌ای ویزارد بارگذاری می‌شود و کاربر می‌تواند فایل فاکتور را نیز ضمیمه کند.
### Team access & attribution
---
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`
تنظیمات بات از طریق فایل `.env` در ریشه پروژه خوانده می‌شود:
## Quick start (Docker)
```env
BALE_BOT_TOKEN=توکن_دریافتی_از_بات_فادر_بله
BALE_API_BASE=https://tapi.bale.ai
AI_BASE_URL=https://api.deepseek.com
AI_API_KEY=کلید_سرویس_هوش_مصنوعی
AI_LLM_MODEL=deepseek-flash
BALE_ADMIN_CHAT_IDS=شناسه_عددی_مدیران_در_بله
```bash
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY
docker build -t procurement-ai-agent .
docker run -d --name procurement-bot --env-file .env \
--add-host host.docker.internal:host-gateway \
procurement-ai-agent
docker logs -f procurement-bot
```
راهنمای گام‌به‌گام اتصال و عیب‌یابی در [docs/SETUP.md](docs/SETUP.md) در دسترس است.
Or as part of the ERP stack — see [`integration/`](integration/README.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
```
+59 -109
View File
@@ -15,13 +15,12 @@ import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps {
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
value: string | null;
onChange: (val: string | null) => void;
placeholder?: string;
className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
withTime?: boolean;
disabled?: boolean;
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
}
function todayJalali(): [number, number, number] {
@@ -43,20 +42,16 @@ function isoToJalali(iso: string | null): [number, number, number] | null {
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd, h, m).toISOString();
}
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
function buildDateOnly(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } {
const d = new Date();
return { h: d.getHours(), m: d.getMinutes() };
@@ -69,7 +64,6 @@ function timeOf(iso: string | null): { h: number; m: number } {
return { h: d.getHours(), m: d.getMinutes() };
}
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
export function JalaliDatePicker({
value,
onChange,
@@ -77,25 +71,14 @@ export function JalaliDatePicker({
className,
withTime = false,
disabled = false,
maxDate,
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const initialTime = timeOf(value);
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 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 [text, setText] = useState<string>(() => {
if (!value) return "";
const j = isoToJalaliStr(value);
@@ -124,7 +107,6 @@ export function JalaliDatePicker({
const mm = String(t.m).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
if (curH !== t.h) {
setHourText(faNum(hh));
@@ -140,7 +122,18 @@ export function JalaliDatePicker({
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم
// تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
const handleOpen = () => {
if (disabled) return;
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
const isLeftHalf = rect.left + rect.width / 2 < window.innerWidth / 2;
// اگر فیلد در نیمه چپ صفحه بود، تراز را از لبه چپ (end) می‌بندیم تا به سمت راست باز شود
setAlignment(isLeftHalf ? "end" : "start");
}
setOpen(true);
};
function selectedLocalDate(): string | null {
if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
@@ -154,20 +147,19 @@ export function JalaliDatePicker({
const grid = useMemo(() => {
const [jy, jm] = curJ;
const firstG = jalaliToGregorian(jy, jm, 1);
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7;
const dim = daysInMonth(jy, jm);
const today = new Date();
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; disabled: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false });
const cells: { d: number; iso: string; isToday: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
const isDisabled = !!(maxDateIso && iso > maxDateIso);
cells.push({ d, iso, isToday: iso === tIso, disabled: isDisabled });
cells.push({ d, iso, isToday: iso === tIso });
}
return cells;
}, [curJ, maxDateIso]);
}, [curJ]);
const nav = (dir: string) => {
setCurJ(([jy, jm]) => {
@@ -190,22 +182,10 @@ export function JalaliDatePicker({
};
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;
let currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
let currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
let validH = Math.min(23, Math.max(0, currentH));
let validM = Math.min(59, Math.max(0, currentM));
if (maxDateObj && dayIso === maxDateIso) {
if (validH > maxDateObj.getHours() || (validH === maxDateObj.getHours() && validM > maxDateObj.getMinutes())) {
validH = maxDateObj.getHours();
validM = maxDateObj.getMinutes();
setHourText(faNum(String(validH).padStart(2, "0")));
setMinuteText(faNum(String(validM).padStart(2, "0")));
}
}
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
const validM = Math.min(59, Math.max(0, currentM));
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val);
setText(
@@ -216,11 +196,8 @@ export function JalaliDatePicker({
const handleHourChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (digits.length === 3) digits = digits.slice(-1);
else if (digits.length > 3) digits = digits.slice(0, 2);
if (!digits) {
setHourText("");
return;
@@ -254,11 +231,8 @@ export function JalaliDatePicker({
const handleMinuteChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (digits.length === 3) digits = digits.slice(-1);
else if (digits.length > 3) digits = digits.slice(0, 2);
if (!digits) {
setMinuteText("");
return;
@@ -293,24 +267,9 @@ export function JalaliDatePicker({
const pickToday = () => {
const now = new Date();
const [jy, jm, jd] = todayJalali();
let nh = now.getHours();
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")));
setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime
? buildIso(jy, jm, jd, nh, nm)
: buildDateOnly(jy, jm, jd);
const nh = now.getHours();
const nm = now.getMinutes();
const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd);
onChange(val);
setText(
withTime
@@ -401,7 +360,7 @@ export function JalaliDatePicker({
};
return (
<div ref={ref} className={cn("relative", className)}>
<div ref={ref} className={cn("relative w-full", className)}>
<input
type="text"
disabled={disabled}
@@ -409,65 +368,57 @@ export function JalaliDatePicker({
value={text}
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
autoComplete="off"
onFocus={() => !disabled && setOpen(true)}
onClick={() => !disabled && setOpen(true)}
onFocus={handleOpen}
onClick={handleOpen}
onChange={(e) => onInputChange(e.target.value)}
onBlur={handleInputBlur}
/>
{open && !disabled && (
<div
className={cn(
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
// حداکثر عرض = viewport (موبایل باریک سرریز نمی‌شود؛ دسکتاپ بی‌تغییر)
"max-w-[calc(100vw-2rem)]",
withTime ? "w-80 p-3" : "w-64 p-2",
// پاپ‌آپ مطلق که بر اساس کلاس تراز تنظیم می‌شود
"absolute top-full z-[100] mt-1",
alignment === "start" ? "start-0" : "end-0", // اگر فیلد چپ بود -> end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست)
"rounded-xl border border-line bg-panel shadow-2xl p-3.5",
withTime ? "w-80" : "w-[280px]",
"max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
)}
>
{/* 1. Header: Month / Year Navigation */}
<div className="mb-1.5 flex items-center justify-between gap-1">
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
<div className="mb-2 flex items-center justify-between gap-1">
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
«
</button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
‹
</button>
<span className="text-[13px] font-bold">
<span className="text-sm font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
›
</button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
»
</button>
</div>
{/* 2. Day of Week Header & Days Grid */}
<div className="grid grid-cols-7 gap-0.5 text-center">
<div className="grid grid-cols-7 gap-1 text-center">
{WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
<div key={w} className="py-1 text-[11px] font-bold text-muted">
{w}
</div>
))}
{grid.map((cell, i) =>
cell.d === 0 ? (
<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
key={i}
type="button"
className={cn(
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
"cursor-pointer rounded-lg py-2 text-[13px] hover:bg-slate-bg transition-colors",
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
@@ -480,44 +431,43 @@ export function JalaliDatePicker({
)}
</div>
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
{/* 3. Time Inputs Row */}
{withTime && (
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
<div className="mt-3 flex items-center justify-center gap-3 rounded-lg border border-line/70 bg-slate-bg/40 py-2">
<div className="flex items-center gap-1.5">
<input
type="text"
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={minuteText}
inputMode="numeric"
onFocus={(e) => e.target.select()}
onChange={(e) => handleMinuteChange(e.target.value)}
onBlur={handleMinuteBlur}
/>
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
<span className="text-xs font-semibold text-muted">دقیقه</span>
</div>
<span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5">
<input
type="text"
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={hourText}
inputMode="numeric"
onFocus={(e) => e.target.select()}
onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur}
/>
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
<span className="text-xs font-semibold text-muted">ساعت</span>
</div>
</div>
)}
{/* 4. Action Buttons Footer */}
<div className="mt-2 flex justify-between border-t border-line pt-2">
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
<div className="mt-3 flex justify-between border-t border-line pt-2">
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-accent hover:bg-slate-bg" onClick={pickToday}>
امروز
</button>
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-muted hover:bg-slate-bg" onClick={clear}>
پاک کردن
</button>
</div>
@@ -525,4 +475,4 @@ export function JalaliDatePicker({
)}
</div>
);
}
}
-1
View File
@@ -172,7 +172,6 @@ export interface InventoryTransaction {
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
created_at?: string;
note?: string | null;
material?: { id: string; name: string } | null;
brand?: { id: string; name: string } | null;
@@ -81,32 +81,7 @@ export function ChangeInventoryPage() {
const [matTxt, setMatTxt] = useState("");
const [brandTxt, setBrandTxt] = useState("");
const [qty, setQty] = useState("");
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];
return !!(raw && raw.trim() !== "" && !Number.isNaN(new Date(raw).getTime()));
};
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
@@ -196,13 +171,10 @@ export function ChangeInventoryPage() {
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 toServerIso = (t: FormType) => {
const toServerIso = () => {
try {
const raw = sectionDates[t];
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
const d = new Date(dateInput);
if (!Number.isNaN(d.getTime())) return d.toISOString();
} catch { /* نادیده */ }
return new Date().toISOString();
};
@@ -461,15 +433,13 @@ export function ChangeInventoryPage() {
const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: unitId, specs, note,
transaction_date: toServerIso("PURCHASE"),
transaction_date: toServerIso(),
};
await postOne(op);
toast.success("ثبت شد ✓");
notifyDataChanged("inventory");
const pIso = toServerIso("PURCHASE");
const pTime = new Date(pIso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
setSessionLog((prev) => [
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: pTime, whName: whName(warehouses, whId) },
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: new Date().toTimeString().slice(0, 5), whName: whName(warehouses, whId) },
...prev,
]);
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
@@ -528,7 +498,7 @@ export function ChangeInventoryPage() {
unit_id: w.unit?.id ?? null,
specs: w.specs || {},
note: buildReturnNote(retNote[w.id], w.id),
transaction_date: toServerIso("RETURN"),
transaction_date: new Date().toISOString(),
});
okCount++;
setRetDone((prev) => new Set(prev).add(w.id));
@@ -580,7 +550,7 @@ export function ChangeInventoryPage() {
quantity: parseNum(ptQty[r.id]),
specs: r.specs || {},
note,
transfer_date: toServerIso("PROJECT_TRANSFER"),
transfer_date: new Date().toISOString(),
}, user?.project_id);
ok++;
setPtSucceeded((prev) => new Set(prev).add(r.id));
@@ -616,7 +586,7 @@ export function ChangeInventoryPage() {
const rec = formRecords[i];
if (!rec) return null;
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("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: t,
@@ -628,7 +598,7 @@ export function ChangeInventoryPage() {
specs: rec.specs || {},
note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(t),
transaction_date: toServerIso(),
};
})
.filter((x): x is Record<string, unknown> => x !== null);
@@ -643,7 +613,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null;
const rec = formRecords[i];
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("TRANSFER") };
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() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -659,7 +629,7 @@ export function ChangeInventoryPage() {
if (!item) return null;
if (n > item.remaining) return null;
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: toServerIso("FOUND") };
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() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
@@ -682,7 +652,7 @@ export function ChangeInventoryPage() {
brand_id: rec.brand?.id || null,
physical_qty: n,
note: r.note,
transaction_date: toServerIso("STOCK_COUNT"),
transaction_date: toServerIso(),
});
confirm.push({
material: rec.material.name,
@@ -716,9 +686,6 @@ export function ChangeInventoryPage() {
};
const doSubmit = () => {
if (!isSectionDateValid(type)) {
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است`);
}
try {
if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch();
@@ -733,7 +700,6 @@ export function ChangeInventoryPage() {
};
const canSubmit = (): boolean => {
if (!isSectionDateValid(type)) return false;
if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0);
}
@@ -979,7 +945,21 @@ export function ChangeInventoryPage() {
</div>
{type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
<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 className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
@@ -992,128 +972,43 @@ export function ChangeInventoryPage() {
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">
<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>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS") && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
{type === "DAMAGE" ? "تاریخ و ساعت وقوع خرابی" : type === "WASTE" ? "تاریخ و ساعت ثبت ضایعات" : "تاریخ و ساعت ثبت اتلاف"} <span className="text-rose-500 font-bold">*</span>
</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
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>
)}
{type === "STOCK_COUNT" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت انجام انبارگردانی / شمارش <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.STOCK_COUNT}
onChange={(iso) => setSectionDate("STOCK_COUNT", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</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>
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
<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>
)}
{/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */}
{isTransfer && (
<div className="grid gap-3 sm:grid-cols-2 mb-3">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت انتقال بین انبارها <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.TRANSFER}
onChange={(iso) => setSectionDate("TRANSFER", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</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 className="field mb-3">
<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>
)}
@@ -1178,15 +1073,12 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و زمان (شمسی) <span className="text-rose-500 font-bold">*</span>
</label>
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
<JalaliDatePicker
value={sectionDates.PURCHASE}
onChange={(iso) => setSectionDate("PURCHASE", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
value={dateInput}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
placeholder="۱۴۰۳/۰۵/۱۲"
withTime
maxDate={new Date()}
/>
</div>
<div className="field md:col-span-2">
@@ -1205,32 +1097,18 @@ export function ChangeInventoryPage() {
</div>
)}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */}
{type === "PROJECT_TRANSFER" && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت انتقال بین کارگاهی <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.PROJECT_TRANSFER}
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 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 className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1257,33 +1135,19 @@ export function ChangeInventoryPage() {
</div>
)}
{/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشت‌ها ---------- */}
{/* ---------- برگشت: فیلتر بازه + لیست برداشت‌ها ---------- */}
{type === "RETURN" && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت ثبت برگشت به انبار <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.RETURN}
onChange={(iso) => setSectionDate("RETURN", 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>
<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 className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1344,28 +1208,14 @@ export function ChangeInventoryPage() {
)}
{type === "FOUND" && (
<div className="space-y-3">
<div className="field max-w-sm">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت پیدا شدن کالا <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
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>
<FoundTable
items={pendingLosses}
rows={rows}
setRows={setRows}
unitNameFn={(id) => unitName(data.lookup.units, id)}
emptyMsg={foundEmptyMsg}
loading={foundLoading && !pendingLosses.length}
/>
)}
{/* دکمه ثبت — دسکتاپ */}
@@ -1683,7 +1533,7 @@ export function ChangeInventoryPage() {
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: type === "RETURN"
? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند."
: `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || new Date()))}`}
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div>
</PageLayout>
@@ -140,7 +140,7 @@ export function DailyControlPage() {
if (t.transaction_date > g.last) g.last = t.transaction_date;
}
});
return [...gmap.values()].sort((a, b) => new Date(b.last).getTime() - new Date(a.last).getTime());
return [...gmap.values()];
}, [withdrawals]);
const isToday = sel === todayKey();
+10 -155
View File
@@ -12,22 +12,6 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
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 }> = {
@@ -74,20 +58,10 @@ export function InventoryPage() {
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams();
const [filters, setFilters] = useState<{
warehouse: string;
material: string;
project: string;
transactionType: string;
dateFrom: string | null;
dateTo: string | null;
}>({
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
warehouse: params.get("warehouse") || "",
material: "",
project: "",
transactionType: "",
dateFrom: null,
dateTo: null,
});
const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -103,40 +77,14 @@ export function InventoryPage() {
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 gmap = new Map<string, Group>();
const order: string[] = [];
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
inventory.forEach((r) => {
const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return;
if (filters.warehouse && whId !== filters.warehouse) 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)}`;
let g = gmap.get(gk);
if (!g) {
@@ -156,6 +104,7 @@ export function InventoryPage() {
order.push(gk);
}
g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r);
if (r.brand) {
if (!(r.brand.id in g.brands)) {
@@ -165,61 +114,26 @@ export function InventoryPage() {
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)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.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());
return out;
}, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
}, [inventory, visibleIds, filters, sort]);
const groupTxs = (g: Group) =>
filteredTransactions
transactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice()
.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;
});
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const brandTxs = (g: Group, bid: string) =>
filteredTransactions
transactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice()
.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;
});
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(
filters.warehouse ||
(isAdmin && filters.project) ||
filters.material ||
filters.transactionType ||
filters.dateFrom ||
filters.dateTo
);
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />;
if (!user) {
@@ -371,72 +285,13 @@ export function InventoryPage() {
</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 && (
<div className="pt-1 flex items-center justify-between">
<button
type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
>
<X className="h-3 w-3" />
پاک کردن فیلترها
+67 -35
View File
@@ -1,59 +1,91 @@
# PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت (Aramesh PM)
# PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت
پلتفرم تخصصی مدیریت پروژه‌های عمرانی، کنترل کیفیت و زمان‌بندی کارگاهی که به صورت وندور شده و یکپارچه در کنار سامانه ERP قرار دارد.
اپلیکیشن موبایل‌محور و تحت وب برای مدیریت جامع پروژه‌های عمرانی و ساخت: مدیریت سلسله‌مراتب فعالیت‌ها (WBS)، زمان‌بندی و گانت چارت، مسیر بحرانی (CPM)، سیستم پیشرفته کنترل کیفیت (QC)، مدیریت مصالح و انبار (با اتصال به ERP) و داشبورد عملیاتی روزانه.
---
## 🏗️ نحوه استقرار و دسترسی در سامانه ERP
## دسترسی سریع
پلتفرم PM در استک یکپارچه پروژه از دو سرویس داکر تشکیل شده است:
* **`pm-backend` (`pm-api`):** وب‌سرویس پایتون مبتنی بر FastAPI روی شبکه داخلی استک.
* **`pm-frontend` (`pm-frontend`):** اپلیکیشن کلاینت React با سرور داخلی Nginx که از طریق ریورس‌پروکسی Traefik زیر مسیر `/pm` قرار گرفته است.
| سامانه | آدرس شبکه محلی | آدرس محلی سیستم |
|---|---|---|
| **PM (وب + نمای موبایل)** | http://192.168.1.103:8083 | http://localhost:8083 |
| **PM API Health** | http://192.168.1.103:8083/api/v1/health | http://localhost:8083/api/v1/health |
### راه‌های دسترسی:
1. **از داخل رابط کاربری ERP:** با ورود به حساب کاربری (نقش‌های مجاز یا پنل PM) و انتخاب منوی **«مدیریت پروژه (PM)»** در آدرس `/panel/pm` (به صورت آی‌فریم روان با سشن یکپارچه).
2. **دسترسی مستقیم مرورگر:** در آدرس `http://localhost/pm` (یا دامنه عملیاتی با پسوند `/pm/`).
**ورود پیش‌فرض:** `admin` / `pm123456` (قابل تغییر از طریق فایل `.env`)
---
## 🗄️ پایگاه داده و احراز هویت مشترک
## معماری سامانه
* **پایگاه‌داده مشترک:** سرویس PM مستقیماً به همان دیتابیس `procurement` در کانتینر Postgres متصل می‌شود. جداول اختصاصی PM (`pm_projects`, `pm_tasks`, `pm_qc_*`, `pm_defects`, ...) در زمان بوت اولیه به صورت خودکار و بدون تداخل با جداول تدارکات ایجاد می‌شوند (`schema_sync.py`).
* **احراز هویت یکپارچه (SSO):** ورود با همان حساب کاربری Casdoor انجام شده و سطح دسترسی پنل PM (`user_panels.pm`) کنترل می‌شود.
```
PM Frontend (React 19, Tailwind CSS, تقویم شمسی, نمای موبایل و دسکتاپ)
│ /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)
* ساختار شکست کار با عمق نامحدود (فاز، بخش، فعالیت، زیرفعالیت).
* روابط وابستگی چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) به همراه زمان وقفه (Lag).
* محاسبه خودکار مسیر بحرانی (Critical Path Method) و شناوری کل فعالیت‌ها.
* گانت چارت شمسی و تعریف مایلستون‌های کلیدی پروژه.
### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC Module)
* **قالب‌های عمومی بازرسی (QC Templates):** تعریف چک‌لیست‌های استاندارد کیفیت برای انواع فعالیت‌های ساختمانی.
* **مراحل ارزیابی:**
* **پیش از اجرا (`pre`):** دروازه ورود به تسک؛ تا زمان تایید این مرحله، پیشرفت تسک نمی‌تواند بیشتر از ۰٪ شود.
* **حین اجرا (`during`):** ثبت بازرسی‌های فنی حین عملیات اجرایی.
* **پس از اجرا (`after`):** کنترل نهایی؛ تا زمان تایید تمامی سوالات الزامی، پیشرفت تسک اجازه ۱۰۰٪ شدن ندارد.
* **ثبت و پیگیری عیوب (Defects):** ثبت عیوب همراه با تصاویر، سطح بحرانی بودن و لزوم بازرسی مجدد پس از اقدامات اصلاحی.
### ۳. مدیریت منابع و اتصال به انبار کارگاه
* امکان تخصیص متریال و ثبت رزرو نرم با استعلام موجودی واقعی انبارهای متناظر در ERP.
| حوزه داده | متولی و مالک | توضیحات |
|---|---|---|
| پروژه‌ها، فعالیت‌ها، وابستگی‌ها، مایلستون‌ها | **PM Platform** | مدیریت کامل در دیتابیس بومی PM |
| چک‌لیست‌ها و کنترل کیفیت (QC) | **PM Platform** | پیش از اجرا، حین اجرا و پس از اجرا |
| ثبت عیوب و اقدامات اصلاحی | **PM Platform** | الصاق تصویر، شدت عیب، پیگیری رفع |
| کاتالوگ مصالح و موجودی واقعی انبار | **ERP** | استعلام برخط موجودی از طریق API |
| رزرو مصالح و درخواست‌های خرید | **PM Platform** | رزرو نرم بدون دستکاری انبار تا زمان تأیید |
---
## 🧪 اجرای آزمایشی مستقل (Standalone Dev)
## قابلیت‌های کلیدی
### ۱. مدیریت زمان‌بندی و گانت چارت (CPM Engine)
- ایجاد پروژه‌ها با کدهای اختصاصی و تیم پروژه
- ساختار شکست کار (WBS) در عمق‌های نامحدود (فاز، بخش، فعالیت، زیرفعالیت)
- وابستگی‌های چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) همراه با روزهای تأخیر (Lag)
- محاسبه خودکار مسیر بحرانی (Critical Path) و شناوری کل فعالیت‌ها
- تعریف مایلستون‌ها و نقاط عطف کلیدی پروژه
### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC)
- **مراحل ارزیابی:** پیش از اجرا (`pre`) · حین اجرا (`during`) · پس از اجرا (`after`)
- **قوانین دروازه‌ای سرور:**
- فعالیت تا زمان تأیید الزامات پیش از اجرا اجازه شروع (پیشرفت > ۰) ندارد.
- فعالیت تا زمان پاس شدن همه الزامات الزامی اجازه رسیدن به ۱۰۰٪ ندارد.
- ثبت عیوب همراه با تصاویر و الزام به بازرسی مجدد پس از اصلاح.
### ۳. مدیریت منابع و ارتباط با ERP
- تخصیص نفرات، ماشین‌آلات و مصالح به تفکیک فعالیت
- اتصال پروژه به پروژه متناظر در ERP جهت استعلام موجودی انبارهای کارگاه
- رزرو نرم مصالح جهت جلوگیری از تداخل مصرف در بخش‌های مختلف پروژه
### ۴. تقویم و تاریخ‌های شمسی
- تقویم بومی جلالی بدون خطا و با رعایت روزهای کاری
- ثبت و نمایش روان تاریخ‌ها در رابط کاربری به زبان فارسی
---
## راه‌اندازی و اجرا
اجرای آسان با اسکریپت پاورشل یا داکر کامپوز:
در صورت تمایل به اجرای مستقل پلتفرم PM برای تست‌های ایزوله:
```powershell
# از درون پوشه pm:
# اجرای خودکار از پوشه PM:
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
این حالت روی پورت محلی `8083` به صورت ایزوله اجرا خواهد شد.
یا مستقیماً با داکر:
```powershell
docker compose up -d --build
```
---
## 📑 قرارداد و مستندات API
قرارداد جامع اندپوینت‌ها و نمونه پیام‌ها در فایل [API_CONTRACT.md](API_CONTRACT.md) و راهنمای تفصیلی ساختار جداول در [DATABASE_GUIDE.md](DATABASE_GUIDE.md) مستند شده است.
## قرارداد API
مستندات کامل اندپوینت‌ها: [`API_CONTRACT.md`](API_CONTRACT.md)
+3 -4
View File
@@ -25,7 +25,7 @@ services:
dockerfile: Dockerfile
container_name: pm-api
environment:
PM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@procurement-postgres:5432/procurement
PM_DATABASE_URL: postgresql+asyncpg://pm:${PM_DB_PASSWORD:-pm_secret_2026}@pm-postgres:5432/pm
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
@@ -48,7 +48,7 @@ services:
condition: service_healthy
networks:
- default
- erp_default
- traefik_network
restart: unless-stopped
pm-frontend:
@@ -85,5 +85,4 @@ networks:
erp_default:
external: true
traefik_network:
external: true
external: true
File diff suppressed because it is too large Load Diff
+37 -32
View File
@@ -87,6 +87,15 @@ export function TaskEditModal({
const [errorMsg, setErrorMsg] = useState("");
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
const isDateRangeInvalid = Boolean(
plannedStart &&
plannedEnd &&
isoToApiDate(plannedStart) &&
isoToApiDate(plannedEnd) &&
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
);
useEffect(() => {
if (open && task) {
setTitle(task.title || task.subject || "");
@@ -112,7 +121,7 @@ export function TaskEditModal({
const taskId = task ? String(task.id || task.op_wp_id) : "";
// لیست والدهای مجاز (حذف خود تسک و تمام زیرتسک‌های آن برای جلوگیری از حلقه)
// لیست والدهای مجاز
const validParents = useMemo(() => {
if (!taskId) return allTasks;
const descendantIds = getDescendantIds(allTasks, taskId);
@@ -122,12 +131,11 @@ export function TaskEditModal({
});
}, [allTasks, taskId]);
// لیست وظایف مجاز جهت برقراری رابطه وابستگی (حذف خود تسک)
// لیست وظایف مجاز جهت برقراری رابطه وابستگی
const validRelationTargets = useMemo(() => {
return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId);
}, [allTasks, taskId]);
// کوئری روابط پروژه جهت استخراج روابط ورودی و خروجی این وظیفه
const relationsQuery = useQuery({
queryKey: ["project-relations", projectId],
queryFn: () => getProjectRelations(projectId),
@@ -161,7 +169,7 @@ export function TaskEditModal({
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)");
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
},
});
@@ -179,12 +187,9 @@ export function TaskEditModal({
},
});
/** Save the whole form. Progress goes to its own endpoint (see below). */
const updateMutation = useMutation({
mutationFn: async (payload: UpdateTaskPayload) => {
await updateTask(task!.id || task!.op_wp_id, payload);
// progress is NOT part of the task patch schema — it has its own endpoint,
// which also enforces the QC gating rules server-side
if (typeof progress === "number" && progress !== (task!.progress ?? task!.percentage_done ?? 0)) {
await updateTaskProgress(task!.id || task!.op_wp_id, progress);
}
@@ -232,7 +237,6 @@ export function TaskEditModal({
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
@@ -242,13 +246,11 @@ export function TaskEditModal({
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
setErrorMsg("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد");
return;
}
// ۲. محدودیت نسبت به والد انتخابی
if (parentId) {
const selectedParent = allTasks.find(
(t) => String(t.id || t.op_wp_id) === String(parentId)
@@ -281,7 +283,6 @@ export function TaskEditModal({
}
}
// ۳. محدودیت نسبت به فرزندان موجود
const children = allTasks.filter((t) => {
const p = t.parent_id
? String(t.parent_id)
@@ -410,20 +411,20 @@ export function TaskEditModal({
<label className="mb-1 block text-xs font-semibold text-slate-600">
پیشرفت (٪)
</label>
<input
type="number"
min={0}
max={100}
dir="ltr"
value={progress}
onChange={(e) => {
const raw = e.target.value;
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
}}
placeholder="۰"
className={`${inputCls} text-left`}
/>
</div>
<input
type="number"
min={0}
max={100}
dir="ltr"
value={progress}
onChange={(e) => {
const raw = e.target.value;
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
}}
placeholder="۰"
className={`${inputCls} text-left`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
@@ -443,7 +444,6 @@ export function TaskEditModal({
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
@@ -483,6 +483,14 @@ export function TaskEditModal({
</div>
</div>
{/* کادر اخطار بلادرنگ در صورت نامعتبر بودن بازه زمانی */}
{isDateRangeInvalid && (
<div className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-2.5 text-xs">
<p className="font-semibold text-slate-700 mb-1">تاریخ‌های واقعی (محاسبه و ثبت خودکار):</p>
<div className="grid grid-cols-2 gap-2 text-slate-600">
@@ -550,7 +558,6 @@ export function TaskEditModal({
setMaterials={setMaterials}
/>
{/* بخش روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF) */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
@@ -564,7 +571,6 @@ export function TaskEditModal({
)}
</div>
{/* لیست وابستگی‌های موجود */}
<div className="space-y-1.5">
{currentTaskRelations.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400">
@@ -619,7 +625,6 @@ export function TaskEditModal({
)}
</div>
{/* فرم افزودن رابطه جدید */}
<div className="pt-2 border-t border-slate-200/80 space-y-2">
<span className="text-[11px] font-bold text-slate-700 block">افزودن رابطه وابستگی جدید</span>
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2">
@@ -723,7 +728,7 @@ export function TaskEditModal({
</button>
<button
type="submit"
disabled={updateMutation.isPending}
disabled={updateMutation.isPending || isDateRangeInvalid}
className={btnPrimary}
>
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
@@ -733,4 +738,4 @@ export function TaskEditModal({
</form>
</Modal>
);
}
}
+95 -38
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -48,7 +48,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
const [resolutionNotes, setResolutionNotes] = useState("");
// Load Task QC Data
// دریافت اطلاعات QC وظیفه
const qcQuery = useQuery({
queryKey: ["task-qc", taskId],
queryFn: () => getTaskQc(taskId),
@@ -66,7 +66,50 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const questions = definitions.flatMap((d) => d.questions);
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
// Mutations
// محاسبه زنده و دقیق آمار هر مرحله بر اساس لیست واقعی سوالات
const preStats = useMemo(() => {
if (questions.length > 0) {
const list = questions.filter((q) => q.phase === "pre");
return {
total: list.length,
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
};
}
return {
total: readiness?.pre?.total ?? 0,
passed: readiness?.pre?.passed ?? 0,
};
}, [questions, readiness?.pre]);
const duringStats = useMemo(() => {
if (questions.length > 0) {
const list = questions.filter((q) => q.phase === "during");
return {
total: list.length,
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
};
}
return {
total: readiness?.exec?.total ?? 0,
passed: readiness?.exec?.passed ?? 0,
};
}, [questions, readiness?.exec]);
const afterStats = useMemo(() => {
if (questions.length > 0) {
const list = questions.filter((q) => q.phase === "after");
return {
total: list.length,
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
};
}
return {
total: (readiness as any)?.after?.total ?? 0,
passed: (readiness as any)?.after?.passed ?? 0,
};
}, [questions, readiness]);
// جهش‌ها (Mutations)
const invalidateQc = () => {
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
@@ -93,7 +136,6 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => {
if (!canExecuteQc || answerMutation.isPending) return;
if (currentAnswer === newAnswer) {
// کلیک مجدد: لغو انتخاب قبلی
answerMutation.mutate({ questionId, answer: null });
} else {
answerMutation.mutate({ questionId, answer: newAnswer });
@@ -158,18 +200,15 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
},
});
// Calculate Progress
const totalQuestions = readiness?.all.total ?? questions.length;
const pendingCount = readiness?.all.pending ?? 0;
const answeredCount = totalQuestions - pendingCount;
// محاسبه پیشرفت کل چک‌لیست
const totalQuestions = questions.length > 0 ? questions.length : (readiness?.all.total ?? 0);
const answeredCount = questions.length > 0
? questions.filter((q) => q.result?.answer != null).length
: (totalQuestions - (readiness?.all.pending ?? 0));
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
// Group stats by phase
const preStats = readiness?.pre;
const execStats = readiness?.exec;
return (
<div className="space-y-3.5">
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
@@ -206,12 +245,13 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div>
)}
{/* نوار پیشرفت کلی QC */}
{totalQuestions > 0 && (
<div className="mt-2.5">
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
<span>پیشرفت بازرسی‌های کیفی</span>
<span className="font-bold tabular-nums text-slate-700">{qcPercent}٪ ({answeredCount} از {totalQuestions})</span>
<span className="font-bold tabular-nums text-slate-700">
{qcPercent}٪ ({answeredCount} از {totalQuestions})
</span>
</div>
<ProgressBar value={qcPercent} />
</div>
@@ -252,52 +292,72 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div>
</div>
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */}
<div className="flex rounded-lg bg-slate-200/80 p-0.5 text-xs font-semibold text-slate-600">
{/* ۳ تب فازها با عرض مساوی و شمارنده دقیق */}
<div className="grid grid-cols-3 gap-1 rounded-lg bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
<button
type="button"
onClick={() => setActivePhase("pre")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
activePhase === "pre"
? "bg-white text-amber-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۱. پیش از اجرا</span>
{preStats && preStats.total > 0 && (
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "pre" ? "bg-amber-100 text-amber-800" : "bg-slate-300 text-slate-700"}`}>
{preStats.passed}/{preStats.total}
</span>
)}
<span className="truncate">۱. پیش از اجرا</span>
<span
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "pre"
? "bg-amber-100 text-amber-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{preStats.passed}/{preStats.total}
</span>
</button>
<button
type="button"
onClick={() => setActivePhase("during")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
activePhase === "during"
? "bg-white text-sky-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۲. حین اجرا</span>
{execStats && execStats.total > 0 && (
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "during" ? "bg-sky-100 text-sky-800" : "bg-slate-300 text-slate-700"}`}>
{execStats.passed}/{execStats.total}
</span>
)}
<span className="truncate">۲. حین اجرا</span>
<span
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "during"
? "bg-sky-100 text-sky-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{duringStats.passed}/{duringStats.total}
</span>
</button>
<button
type="button"
onClick={() => setActivePhase("after")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
activePhase === "after"
? "bg-white text-emerald-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۳. تحویل و پس از اجرا</span>
<span className="truncate">
<span className="sm:hidden">۳. پس از اجرا</span>
<span className="hidden sm:inline">۳. تحویل و پس از اجرا</span>
</span>
<span
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "after"
? "bg-emerald-100 text-emerald-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{afterStats.passed}/{afterStats.total}
</span>
</button>
</div>
@@ -363,12 +423,11 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
)}
</div>
{/* دکمه‌های پاسخ فوری ۳ گانه */}
{/* دکمه‌های ۳ گانه پاسخ */}
<div className="mt-2.5 flex items-center justify-between gap-2 pt-2 border-t border-slate-100">
<span className="text-[11px] text-slate-500">وضعیت بازرسی:</span>
<div className="flex items-center gap-1">
{/* دکمه قبول / Pass */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
@@ -383,7 +442,6 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
<span>قبول</span>
</button>
{/* دکمه رد / Fail */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
@@ -398,7 +456,6 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
<span>مردود</span>
</button>
{/* دکمه مطرح نیست */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
@@ -415,7 +472,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div>
</div>
{/* در صورت قبول بودن: اکشن آپلود عکس تأییدیه */}
{/* در صورت قبول بودن: امکان آپلود تصویر مستند */}
{latestAns === "pass" && (
<div className="mt-2 rounded-lg bg-emerald-50/70 p-2 text-xs border border-emerald-100 space-y-1.5">
<div className="flex items-center justify-between">
@@ -932,4 +989,4 @@ function CreateDefectModal({
</form>
</Modal>
);
}
}
+61 -304
View File
@@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
BarChart3,
Calendar,
ChevronDown,
@@ -15,21 +16,15 @@ import {
RefreshCw,
Search,
Trash2,
UserPlus,
Users,
} from "lucide-react";
import {
addProjectMember,
createTask,
createTaskRelation,
deleteTaskRelation,
getProject,
getProjectGantt,
getTasks,
getUsers,
removeProjectMember,
rescheduleProject,
updateProject,
updateTask,
updateTaskRelation,
} from "../lib/api";
@@ -38,7 +33,6 @@ import type {
CreateTaskPayload,
HumanResourceItem,
MaterialItem,
ProjectMember,
Task,
TaskRelation,
UpdateRelationPayload,
@@ -50,7 +44,6 @@ import {
Chip,
EmptyState,
ErrorState,
InitialAvatar,
Modal,
PageHeader,
ProgressBar,
@@ -142,7 +135,6 @@ function TreeRow({
const displayTitle = t.title || t.subject;
const targetUrl = `/tasks/${t.id || t.op_wp_id}`;
const progress = t.progress ?? t.percentage_done ?? 0;
// رنگ درصد: ۱۰۰٪ سبز، در حال انجام آبی، صفر خاکستری
const progressCls =
progress >= 100 ? "text-emerald-600" : progress > 0 ? "text-sky-600" : "text-slate-400";
@@ -171,7 +163,6 @@ function TreeRow({
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-800 group-hover:text-sky-600 transition-colors">
{displayTitle}
</span>
{/* درصد تکمیل — بلافاصله سمت چپ نام و بالای نوار پیشرفت */}
<span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}>
{progress}٪
</span>
@@ -260,7 +251,6 @@ function TaskCreateModal({
const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [errorMsg, setErrorMsg] = useState("");
// روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF)
interface DraftRelation {
target_task_id: string;
target_task_title: string;
@@ -272,7 +262,16 @@ function TaskCreateModal({
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [relLag, setRelLag] = useState<number>(0);
const [relLag, setRelLag] = useState<number | "">("");
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
const isDateRangeInvalid = Boolean(
plannedStart &&
plannedEnd &&
isoToApiDate(plannedStart) &&
isoToApiDate(plannedEnd) &&
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
);
useEffect(() => {
if (open) {
@@ -280,7 +279,7 @@ function TaskCreateModal({
setSelectedChildIds([]);
setDraftRelations([]);
setRelTargetId("");
setRelLag(0);
setRelLag("");
setErrorMsg("");
}
}, [open, parentTask]);
@@ -290,7 +289,6 @@ function TaskCreateModal({
const created = await createTask(payload);
const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id);
// ۱. انتساب وظایف انتخاب شده به عنوان فرزندان (زیرتسک‌ها)
if (selectedChildIds.length > 0 && newTaskId) {
for (const childId of selectedChildIds) {
try {
@@ -301,7 +299,6 @@ function TaskCreateModal({
}
}
// ۲. ایجاد روابط وابستگی تعریف شده (FS, SS, FF, SF)
if (draftRelations.length > 0 && newTaskId) {
for (const rel of draftRelations) {
try {
@@ -356,7 +353,6 @@ function TaskCreateModal({
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
@@ -366,13 +362,11 @@ function TaskCreateModal({
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
setErrorMsg("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد");
return;
}
// ۳. انطباق با تاریخ‌های والد انتخابی
const activeParent = selectedParentId
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
: null;
@@ -419,7 +413,6 @@ function TaskCreateModal({
});
};
// وظایف کاندید برای فرزند بودن (به جز والدی که هم‌اکنون انتخاب شده)
const candidateChildren = allTasks.filter(
(t) => String(t.id || t.op_wp_id) !== selectedParentId
);
@@ -444,7 +437,6 @@ function TaskCreateModal({
/>
</div>
{/* ۱. تعیین وظیفه والد (PARENT) */}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
وظیفه والد (سطح بالاتر - Parent)
@@ -472,7 +464,6 @@ function TaskCreateModal({
</select>
</div>
{/* ۲. تعیین زیرتسک‌ها و فرزندان (CHILD) */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-700">
@@ -576,6 +567,14 @@ function TaskCreateModal({
</div>
</div>
{/* ارور در صورت کمتر بودن تاریخ پایان از شروع */}
{isDateRangeInvalid && (
<div className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label>
<input
@@ -595,8 +594,9 @@ function TaskCreateModal({
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
onFocus={(e) => e.target.select()}
placeholder="مثال: ۵"
className={inputCls}
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
/>
<p className="mt-1 text-[11px] leading-4 text-slate-400">
روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
@@ -621,7 +621,6 @@ function TaskCreateModal({
setMaterials={setMaterials}
/>
{/* ۳. تعریف روابط وابستگی (FS, SS, FF, SF + lag) در تعریف وظیفه */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
@@ -635,7 +634,6 @@ function TaskCreateModal({
)}
</div>
{/* لیست روابط پیش‌نویس */}
{draftRelations.length > 0 && (
<div className="space-y-1.5">
{draftRelations.map((r, idx) => (
@@ -672,7 +670,6 @@ function TaskCreateModal({
</div>
)}
{/* فرم افزودن رابطه پیش‌نویس */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 pt-1 border-t border-slate-200/80">
<div className="sm:col-span-4">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
@@ -724,8 +721,13 @@ function TaskCreateModal({
<input
type="number"
value={relLag}
onChange={(e) => setRelLag(parseInt(e.target.value) || 0)}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
onChange={(e) => {
const val = e.target.value;
setRelLag(val === "" ? "" : Number(val));
}}
onFocus={(e) => e.target.select()}
placeholder="0"
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
@@ -747,11 +749,11 @@ function TaskCreateModal({
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
direction: relDirection,
relation_type: relType,
lag_days: relLag,
lag_days: Number(relLag) || 0,
},
]);
setRelTargetId("");
setRelLag(0);
setRelLag("");
}}
className="inline-flex h-7 items-center justify-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
@@ -764,7 +766,11 @@ function TaskCreateModal({
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
<button
type="submit"
disabled={createMutation.isPending || isDateRangeInvalid}
className={btnPrimary}
>
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
</button>
</div>
@@ -785,7 +791,6 @@ function CreateRelationModal({
projectId: string;
tasks: Task[];
initialFromTaskId: string | null;
/** در حالت ویرایش: وابستگی موجود؛ دو سر وابستگی تغییرناپذیرند */
editRelation?: TaskRelation | null;
onClose: () => void;
}) {
@@ -794,24 +799,32 @@ function CreateRelationModal({
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
const [toTaskId, setToTaskId] = useState("");
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [lagDays, setLagDays] = useState(0);
const [lagDays, setLagDays] = useState<number | "">("");
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
if (initialFromTaskId) setFromTaskId(initialFromTaskId);
}, [initialFromTaskId]);
// در حالت ویرایش، فرم با مقادیر فعلی وابستگی پر می‌شود
useEffect(() => {
if (!open) return;
if (editRelation) {
setFromTaskId(editRelation.from_task_id);
setToTaskId(editRelation.to_task_id);
setRelationType(editRelation.relation_type);
setLagDays(editRelation.lag_days || 0);
setLagDays(
editRelation.lag_days !== undefined && editRelation.lag_days !== null
? editRelation.lag_days
: ""
);
} else {
setFromTaskId(initialFromTaskId || "");
setToTaskId("");
setRelationType("FS");
setLagDays("");
}
setErrorMsg("");
}, [open, editRelation]);
}, [open, editRelation, initialFromTaskId]);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
@@ -824,7 +837,7 @@ function CreateRelationModal({
invalidate();
onClose();
setToTaskId("");
setLagDays(0);
setLagDays("");
setErrorMsg("");
},
onError: (err: any) => {
@@ -933,9 +946,13 @@ function CreateRelationModal({
<input
type="number"
value={lagDays}
onChange={(e) => setLagDays(Number(e.target.value))}
placeholder="مثلاً ۲ (گیرش بتن)"
className={inputCls}
onChange={(e) => {
const val = e.target.value;
setLagDays(val === "" ? "" : Number(val));
}}
onFocus={(e) => e.target.select()}
placeholder="0"
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
/>
</div>
</div>
@@ -958,113 +975,8 @@ function CreateRelationModal({
{createRelMutation.isPending || updateRelMutation.isPending
? "در حال محاسبه..."
: isEdit
? "ذخیره تغییرات"
: "ثبت وابستگی"}
</button>
</div>
</form>
</Modal>
);
}
function AddMemberModal({
open,
projectId,
onClose,
}: {
open: boolean;
projectId: string;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedUserId, setSelectedUserId] = useState("");
const [memberRole, setMemberRole] = useState("supervisor");
const [notes, setNotes] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const usersQuery = useQuery({ queryKey: ["users"], queryFn: getUsers });
const addMutation = useMutation({
mutationFn: () =>
addProjectMember(projectId, {
user_id: selectedUserId,
role: memberRole,
notes: notes.trim() || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
setSelectedUserId("");
setNotes("");
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در افزودن عضو به پروژه");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUserId) {
setErrorMsg("لطفاً یک کاربر را انتخاب کنید");
return;
}
addMutation.mutate();
};
return (
<Modal open={open} title="افزودن عضو جدید به تیم پروژه" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{errorMsg && <div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">{errorMsg}</div>}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">انتخاب کاربر *</label>
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className={inputCls}
required
>
<option value="">-- انتخاب کاربر --</option>
{(usersQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.display_name} ({u.username}) — {u.role_label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">نقش در پروژه</label>
<select
value={memberRole}
onChange={(e) => setMemberRole(e.target.value)}
className={inputCls}
>
<option value="supervisor">ناظر اجرایی</option>
<option value="manager">مدیر پروژه</option>
<option value="qc_inspector">بازرس کیفیت (QC)</option>
<option value="subcontractor">پیمانکار جزء</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">یادداشت / حدود اختیارات</label>
<input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="یادداشت اختیاری"
className={inputCls}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={addMutation.isPending} className={btnPrimary}>
{addMutation.isPending ? "در حال ثبت..." : "افزودن به تیم"}
? "ذخیره تغییرات"
: "ثبت وابستگی"}
</button>
</div>
</form>
@@ -1082,17 +994,15 @@ export default function ProjectDetailPage() {
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
const projectId = id ?? "";
const [activeTab, setActiveTab] = useState<"tasks" | "gantt" | "members">("tasks");
const [activeTab, setActiveTab] = useState<"tasks" | "gantt">("tasks");
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showTaskModal, setShowTaskModal] = useState(false);
const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRelationModal, setShowRelationModal] = useState(false);
const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null);
/** وابستگی در حال ویرایش (null = حالت تعریف جدید) */
const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null);
const initializedFor = useRef<string | null>(null);
@@ -1157,21 +1067,12 @@ export default function ProjectDetailPage() {
setShowRelationModal(true);
};
/** ویرایش یک وابستگی موجود از داخل کشوی تسک */
const handleEditRelation = (relation: TaskRelation) => {
setRelationFromTaskId(null);
setRelationToEdit(relation);
setShowRelationModal(true);
};
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => removeProjectMember(projectId, memberId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
const deleteRelationMutation = useMutation({
mutationFn: (relId: string) => deleteTaskRelation(projectId, relId),
onSuccess: () => {
@@ -1189,18 +1090,6 @@ export default function ProjectDetailPage() {
},
});
const updateStatusMutation = useMutation({
mutationFn: (newStatus: string) => updateProject(projectId, { status: newStatus }),
onSuccess: () => {
toast.success("وضعیت پروژه با موفقیت تغییر کرد");
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در تغییر وضعیت پروژه");
},
});
const refresh = () => {
void projectQuery.refetch();
void tasksQuery.refetch();
@@ -1247,58 +1136,6 @@ export default function ProjectDetailPage() {
<p className="text-xs font-mono font-semibold text-sky-600">{project.identifier}</p>
<h2 className="text-base font-bold text-slate-800 mt-0.5">{project.name}</h2>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500 font-medium">وضعیت:</span>
{canManageProjects ? (
<select
value={project.status?.toUpperCase() || "ACTIVE"}
onChange={(e) => updateStatusMutation.mutate(e.target.value)}
disabled={updateStatusMutation.isPending}
aria-label="تغییر وضعیت پروژه"
className={`rounded-lg px-2.5 py-1 text-xs font-semibold border transition-all cursor-pointer shadow-sm ${
(project.status || "").toUpperCase() === "ACTIVE"
? "bg-emerald-50 text-emerald-700 border-emerald-300 focus:ring-emerald-200"
: (project.status || "").toUpperCase() === "COMPLETED"
? "bg-sky-50 text-sky-700 border-sky-300 focus:ring-sky-200"
: (project.status || "").toUpperCase() === "ON_HOLD"
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
: (project.status || "").toUpperCase() === "PLANNED" || (project.status || "").toUpperCase() === "PLANNING"
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
: "bg-slate-50 text-slate-700 border-slate-300 focus:ring-slate-200"
}`}
>
<option value="ACTIVE">در حال اجرا (ACTIVE)</option>
<option value="PLANNED">برنامه‌ریزی (PLANNED)</option>
<option value="ON_HOLD">متوقف (ON_HOLD)</option>
<option value="COMPLETED">تکمیل‌شده (COMPLETED)</option>
<option value="CANCELLED">لغو شده (CANCELLED)</option>
</select>
) : (
<Chip
tone={
project.status === "ACTIVE"
? "green"
: project.status === "COMPLETED"
? "sky"
: project.status === "ON_HOLD" || project.status === "PLANNED"
? "amber"
: "slate"
}
>
{project.status === "ACTIVE"
? "در حال اجرا"
: project.status === "PLANNED" || project.status === "PLANNING"
? "برنامه‌ریزی"
: project.status === "ON_HOLD"
? "متوقف"
: project.status === "COMPLETED"
? "تکمیل‌شده"
: project.status === "CANCELLED"
? "لغو شده"
: project.status}
</Chip>
)}
</div>
</div>
{project.location && (
@@ -1348,7 +1185,6 @@ export default function ProjectDetailPage() {
</Card>
)}
{/* ۳ تب: ساختار شکست کار / گانت و زمان‌بندی / اعضای تیم */}
<div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
<button
type="button"
@@ -1368,15 +1204,6 @@ export default function ProjectDetailPage() {
>
نمودار گانت و زمان‌بندی
</button>
<button
type="button"
onClick={() => setActiveTab("members")}
className={`flex-1 rounded-lg py-1.5 text-center transition-all ${
activeTab === "members" ? "bg-white text-sky-700 shadow-sm" : "hover:text-slate-800"
}`}
>
اعضای تیم ({project?.members?.length ?? project?.member_count ?? 0})
</button>
</div>
{activeTab === "tasks" && (
@@ -1489,69 +1316,6 @@ export default function ProjectDetailPage() {
)}
</div>
)}
{activeTab === "members" && (
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700">اعضای اختصاص‌یافته به این پروژه</h3>
{canManageProjects && (
<button
type="button"
onClick={() => setShowMemberModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-50 px-2.5 text-xs font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<UserPlus size={14} />
<span>افزودن عضو</span>
</button>
)}
</div>
{(!project?.members || project.members.length === 0) ? (
<Card>
<EmptyState
icon={Users}
title="عضوی ثبت نشده است"
description="برای تخصیص ناظران یا کارشناسان به این پروژه، دکمه «افزودن عضو» را بزنید."
/>
</Card>
) : (
<div className="space-y-2">
{project.members.map((m: ProjectMember) => (
<Card key={m.id} className="p-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<InitialAvatar name={m.display_name || m.username} className="h-8 w-8" />
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 truncate">
{m.display_name || m.username}
</p>
<p className="text-[11px] text-slate-400">
{m.role === "manager"
? "مدیر پروژه"
: m.role === "supervisor"
? "ناظر اجرایی"
: m.role === "qc_inspector"
? "بازرس QC"
: m.role}
</p>
</div>
</div>
{canManageProjects && (
<button
type="button"
onClick={() => removeMemberMutation.mutate(m.id)}
disabled={removeMemberMutation.isPending}
title="حذف عضو"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors"
>
<Trash2 size={15} />
</button>
)}
</Card>
))}
</div>
)}
</div>
)}
</div>
<TaskCreateModal
@@ -1588,13 +1352,6 @@ export default function ProjectDetailPage() {
setRelationToEdit(null);
}}
/>
<AddMemberModal
open={showMemberModal}
projectId={projectId}
onClose={() => setShowMemberModal(false)}
/>
</>
);
}
}
+147 -113
View File
@@ -62,6 +62,16 @@ import { isoToApiDate } from "../lib/jalali";
const quickValues = [0, 25, 50, 75, 100];
function parseCleanNumber(raw: string): number {
if (!raw) return 0;
const clean = raw
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString())
.replace(/[^0-9.]/g, "");
const val = parseFloat(clean);
return isNaN(val) ? 0 : Math.max(0, val);
}
function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) {
return (
<Card className="p-4">
@@ -71,10 +81,6 @@ function SectionCard({ title, children }: { title: ReactNode; children: ReactNod
);
}
// ----------------------------------------
// صفحه جزئیات وظیفه
// ----------------------------------------
export default function TaskDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -99,7 +105,6 @@ export default function TaskDetailPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [progress, setProgress] = useState(0);
/** ISO میلادی — ارسال با isoToApiDate */
const [startDate, setStartDate] = useState<string | null>(null);
const [dueDate, setDueDate] = useState<string | null>(null);
@@ -109,7 +114,14 @@ export default function TaskDetailPage() {
const [consumedMaterials, setConsumedMaterials] = useState<MaterialConsumption[]>([]);
const [progressNote, setProgressNote] = useState("");
// محاسبه مجموع مصارف ثبت‌شده قبلی از سوابق گزارش‌های وظیفه
const isDateRangeInvalid = Boolean(
startDate &&
dueDate &&
isoToApiDate(startDate) &&
isoToApiDate(dueDate) &&
isoToApiDate(startDate)! > isoToApiDate(dueDate)!
);
const pastConsumptionMap = useMemo(() => {
const map = new Map<string, { total: number; unit: string }>();
if (!task?.progress_logs) return map;
@@ -148,7 +160,6 @@ export default function TaskDetailPage() {
setConsumedHr([]);
}
// یکپارچه‌سازی مصالح تخصیص‌یافته و مصارف قبلی
const items: MaterialConsumption[] = [];
const seen = new Set<string>();
@@ -158,6 +169,7 @@ export default function TaskDetailPage() {
seen.add(key);
const past = pastConsumptionMap.get(key)?.total || 0;
const alloc = Number(m.count) || 0;
const isOver = alloc > 0 && past > alloc;
items.push({
name: m.name,
unit: m.unit || "",
@@ -166,14 +178,13 @@ export default function TaskDetailPage() {
previous_consumed: past,
total_consumed: past,
remaining_amount: Math.max(0, alloc - past),
is_over_allocated: alloc > 0 ? past > alloc : past > 0,
over_allocated_amount: Math.max(0, past - alloc),
is_over_allocated: isOver,
over_allocated_amount: isOver ? past - alloc : 0,
note: "",
});
}
}
// مصالحی که در سوابق قبل مصرف شده‌اند ولی در تخصیص اولیه نبوده‌اند
pastConsumptionMap.forEach((val, key) => {
if (!seen.has(key)) {
items.push({
@@ -184,8 +195,8 @@ export default function TaskDetailPage() {
previous_consumed: val.total,
total_consumed: val.total,
remaining_amount: 0,
is_over_allocated: val.total > 0,
over_allocated_amount: val.total,
is_over_allocated: false,
over_allocated_amount: 0,
note: "خارج از سقف مصوب",
});
}
@@ -197,14 +208,12 @@ export default function TaskDetailPage() {
const invalidateTask = () => {
void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] });
// کلید QC با همان شناسهای است که صفحههای QC استفاده میکنند
void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const progressMutation = useMutation({
// ثبت پیشرفت کار همراه با مصرف واقعی نیروی انسانی و مصالح
mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload),
onSuccess: () => {
toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
@@ -215,8 +224,7 @@ export default function TaskDetailPage() {
});
const handleMaterialAmountChange = (idx: number, rawVal: string) => {
const val = parseFloat(rawVal);
const amt = Math.max(0, isNaN(val) ? 0 : val);
const amt = parseCleanNumber(rawVal);
setConsumedMaterials((prev) => {
const next = [...prev];
const item = { ...next[idx] };
@@ -224,8 +232,8 @@ export default function TaskDetailPage() {
const prevC = item.previous_consumed ?? 0;
const tot = prevC + amt;
const rem = Math.max(0, alloc - tot);
const isOver = alloc > 0 ? tot > alloc : tot > 0;
const overAmt = Math.max(0, tot - alloc);
const isOver = alloc > 0 && tot > alloc;
const overAmt = isOver ? tot - alloc : 0;
item.amount = amt;
item.total_consumed = tot;
@@ -250,7 +258,7 @@ export default function TaskDetailPage() {
...prev,
{
name: "",
unit: "",
unit: "عدد",
amount: 0,
allocated_amount: 0,
previous_consumed: 0,
@@ -258,7 +266,7 @@ export default function TaskDetailPage() {
remaining_amount: 0,
is_over_allocated: false,
over_allocated_amount: 0,
note: "مصالح مازاد / خارج از برنامه",
note: "مصالح خارج از برنامه",
},
]);
};
@@ -273,7 +281,7 @@ export default function TaskDetailPage() {
const start = isoToApiDate(startDate);
const due = isoToApiDate(dueDate);
if (start && due && start > due) {
throw new ApiError("تاریخ شروع یک وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد", 400, "VALIDATION");
throw new ApiError("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد", 400, "VALIDATION");
}
if (start) payload.start_date = start;
if (due) payload.due_date = due;
@@ -286,7 +294,6 @@ export default function TaskDetailPage() {
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخ‌ها ناموفق بود"),
});
// ۱. وضعیت و جهش ویرایش منابع تخصیص‌یافته به وظیفه
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
@@ -310,7 +317,6 @@ export default function TaskDetailPage() {
toast.error(err instanceof ApiError ? err.message : "خطا در به‌روزرسانی منابع وظیفه"),
});
// ۲. وضعیت و جهش‌های مدیریت روابط وابستگی (FS, SS, FF, SF)
const [showAddRelationModal, setShowAddRelationModal] = useState(false);
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
@@ -336,7 +342,7 @@ export default function TaskDetailPage() {
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی نامعتبر)");
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
},
});
@@ -399,8 +405,6 @@ export default function TaskDetailPage() {
const projectLabel = project?.name ?? "—";
const overdue = isOverdue(task.due_date);
// مجوزهای نقش: ناظر پیشرفت را ثبت و بازرسی را اجرا می‌کند، اما ساختار QC و
// تاریخ‌های برنامه و ویرایش تسک را تغییر نمی‌دهد (سرور هم مستقل همین را بررسی می‌کند).
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
@@ -419,7 +423,6 @@ export default function TaskDetailPage() {
const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none");
const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none");
// پیش‌بینی قواعد سرور: شروع نیازمند تأیید «پیش از اجرا» و ۱۰۰٪ نیازمند تأیید همه بازرسی‌ها
const blockedByStart = progress > 0 && !readiness.can_start;
const blockedByComplete = progress >= 100 && !readiness.can_complete;
const progressBlocked = blockedByStart || blockedByComplete;
@@ -451,7 +454,6 @@ export default function TaskDetailPage() {
/>
<div className="space-y-3 px-4 pb-28 pt-4">
{/* کارت اطلاعات اصلی */}
<Card className="p-4">
<div className="flex items-start justify-between gap-2">
<p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p>
@@ -493,7 +495,7 @@ export default function TaskDetailPage() {
</div>
</Card>
{/* کارت یکپارچه پیشرفت، تخصیص و گزارش مصرف مصالح و نیروی انسانی */}
{/* کارت پیشرفت و مدیریت یکپارچه مصالح */}
<SectionCard
title={
<div className="flex items-center justify-between w-full">
@@ -538,7 +540,6 @@ export default function TaskDetailPage() {
className="mt-3 w-full accent-sky-600"
aria-label="درصد پیشرفت"
/>
{/* dir=ltr فقط برای ترتیب چشمی دکمه‌ها: ۰ تا ۱۰۰ از چپ به راست */}
<div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5">
{quickValues.map((v) => (
<button
@@ -572,7 +573,7 @@ export default function TaskDetailPage() {
</p>
)}
{/* ۱. بخش مصالح و تجهیزات: تخصیص، مصارف قبلی، مصرف جاری و مانده */}
{/* بخش مصالح و تجهیزات مصرفی */}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 space-y-3 shadow-2xs">
<div className="flex items-center justify-between border-b border-slate-100 pb-2 gap-2">
<span className="text-xs font-bold text-slate-900 flex items-center gap-1.5 min-w-0">
@@ -582,9 +583,9 @@ export default function TaskDetailPage() {
<button
type="button"
onClick={handleAddCustomMaterial}
className="shrink-0 text-[10px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors"
className="shrink-0 text-[11px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors"
>
<Plus size={12} />
<Plus size={13} />
<span>افزودن مصالح خارج برنامه</span>
</button>
</div>
@@ -594,15 +595,15 @@ export default function TaskDetailPage() {
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. می‌توانید با دکمه بالا مصالح جدید اضافه کنید.
</div>
) : (
<div className="space-y-2.5">
<div className="space-y-3">
{consumedMaterials.map((item, idx) => {
const alloc = item.allocated_amount || 0;
const prevC = item.previous_consumed || 0;
const currentC = item.amount || 0;
const totC = prevC + currentC;
const rem = Math.max(0, alloc - totC);
const isOver = alloc > 0 ? totC > alloc : totC > 0;
const overAmt = Math.max(0, totC - alloc);
const isOver = alloc > 0 && totC > alloc;
const overAmt = isOver ? totC - alloc : 0;
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
const specs =
task?.materials?.find(
@@ -612,40 +613,50 @@ export default function TaskDetailPage() {
return (
<div
key={idx}
className={`rounded-xl border p-2.5 space-y-2.5 bg-white shadow-2xs transition-all ${
className={`rounded-xl border p-3 space-y-3 bg-white shadow-2xs transition-all ${
isOver ? "border-rose-300 ring-1 ring-rose-200 bg-rose-50/10" : "border-slate-200"
}`}
>
{/* ۱. سطر عنوان، واحد و مشخصات کالا */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{alloc === 0 ? (
<div className="flex items-center gap-1.5 flex-wrap">
<input
value={item.name}
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
placeholder="نام مصالح..."
className="h-7 w-36 rounded border border-amber-300 bg-amber-50/40 px-2 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
<input
value={item.unit}
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
placeholder="واحد"
className="h-7 w-16 rounded border border-amber-300 bg-amber-50/40 px-2 text-xs text-slate-700 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full">
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
نام مصالح یا کالا
</label>
<input
value={item.name}
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
placeholder="مثلاً: آجر فشاری، سیمان..."
className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
واحد سنجش
</label>
<input
value={item.unit}
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
placeholder="مثلاً: عدد، تن، پاکت..."
className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
) : (
<div className="flex items-baseline gap-1.5">
<span className="font-extrabold text-slate-900 text-[13px]">{item.name}</span>
{item.unit && <span className="text-[11px] text-slate-400 font-medium">({item.unit})</span>}
<span className="font-extrabold text-slate-900 text-sm">{item.name}</span>
{item.unit && <span className="text-xs text-slate-500 font-medium">({item.unit})</span>}
</div>
)}
{specs.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
<div className="mt-1.5 flex flex-wrap gap-1">
{specs.map((s, si) => (
<span
key={si}
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[9.5px] border border-slate-200"
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[10px] border border-slate-200"
>
{s}
</span>
@@ -654,17 +665,21 @@ export default function TaskDetailPage() {
)}
</div>
{/* نشانگر وضعیت سقف یا دکمه حذف */}
<div className="shrink-0">
<div className="shrink-0 pt-0.5">
{alloc === 0 ? (
<button
type="button"
onClick={() => handleRemoveMaterialRow(idx)}
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
title="حذف ردیف"
>
<Trash2 size={14} />
</button>
<div className="flex items-center gap-1.5">
<span className="rounded-md px-2 py-0.5 text-[10px] font-bold border bg-amber-50 text-amber-700 border-amber-200">
خارج از برنامه
</span>
<button
type="button"
onClick={() => handleRemoveMaterialRow(idx)}
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
title="حذف ردیف"
>
<Trash2 size={15} />
</button>
</div>
) : (
<span
className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${
@@ -673,74 +688,94 @@ export default function TaskDetailPage() {
: "bg-emerald-50 text-emerald-700 border-emerald-200"
}`}
>
{isOver ? "⚠️ مازاد" : "مجاز"}
{isOver ? "⚠️ مازاد بر سقف" : "مجاز"}
</span>
)}
</div>
</div>
{/* ۲. جدول ۳بخشی منظم حسابداری مصالح (مقدار کل مصوب، مصرف تاکنون، مانده قابل مصرف) */}
<div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-1.5 text-center">
<div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-2 text-center text-xs">
<div className="px-1">
<span className="text-slate-400 block text-[9.5px]">مقدار کل (سقف)</span>
<span className="font-bold text-slate-800 text-[11.5px] mt-0.5 block">
{alloc > 0 ? `${alloc} ${item.unit || ""}` : "نامحدود"}
<span className="text-slate-400 block text-[10px]">سقف مصوب</span>
<span className="font-bold text-slate-800 text-xs mt-0.5 block">
{alloc > 0 ? (
<>
<span className="tabular-nums">{alloc}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
</>
) : (
<span className="text-slate-500 font-medium">بدون سقف</span>
)}
</span>
</div>
<div className="px-1">
<span className="text-slate-400 block text-[9.5px]">مصرف تاکنون</span>
<span className="font-bold text-slate-700 text-[11.5px] mt-0.5 block">
{prevC} {item.unit || ""}
<span className="text-slate-400 block text-[10px]">مصرف تاکنون</span>
<span className="font-bold text-slate-700 text-xs mt-0.5 block">
<span className="tabular-nums">{prevC}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
</span>
</div>
<div className="px-1">
<span className={`block text-[9.5px] ${isOver ? "text-rose-600 font-bold" : "text-emerald-700 font-bold"}`}>
{isOver ? "مازاد سقف" : "مانده قابل مصرف"}
<span
className={`block text-[10px] font-bold ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"}
</span>
<span className={`font-black text-[11.5px] mt-0.5 block ${isOver ? "text-rose-600" : "text-emerald-700"}`}>
{isOver ? `+${overAmt} ${item.unit || ""}` : `${rem} ${item.unit || ""}`}
<span
className={`font-black text-xs mt-0.5 block ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? (
<span dir="ltr">+{overAmt} {item.unit}</span>
) : alloc > 0 ? (
<span>{rem} {item.unit}</span>
) : (
<span>{totC} {item.unit}</span>
)}
</span>
</div>
</div>
{/* ۳. ورودی شاخص و مستقل «مصرف این گزارش *» */}
<div className="space-y-1 rounded-lg bg-amber-50/70 border border-amber-200 p-2">
<div className="flex items-center justify-between gap-2">
<label className="text-[11.5px] font-black text-amber-950 flex items-center gap-1.5 shrink-0">
<div className="rounded-lg bg-amber-50/60 border border-amber-200 p-2.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-bold text-amber-950 flex items-center gap-1.5 shrink-0">
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span>مصرف این گزارش *:</span>
</label>
<div className="flex items-center rounded-lg border-2 border-amber-400 bg-white shadow-xs focus-within:border-amber-600 focus-within:ring-2 focus-within:ring-amber-200">
<div className="relative flex items-center w-36">
<input
type="number"
step="any"
min={0}
type="text"
inputMode="decimal"
value={item.amount === 0 ? "" : item.amount}
onFocus={(e) => e.target.select()}
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
placeholder="۰"
className="h-8 w-24 bg-transparent px-2 text-center text-sm font-black text-slate-900 focus:outline-none"
className="h-8 w-full rounded-lg border-2 border-amber-400 bg-white px-3 pe-12 text-center text-sm font-black text-slate-900 focus:border-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-200"
/>
{item.unit && (
<span className="shrink-0 bg-amber-100/90 border-s border-amber-300 px-2 py-1 text-[11px] font-bold text-amber-900 select-none">
<span className="absolute left-2 text-[11px] font-bold text-slate-500 pointer-events-none">
{item.unit}
</span>
)}
</div>
</div>
{currentC > 0 && (
<div className="pt-1 border-t border-amber-200/60 flex items-center justify-between text-[10px] text-amber-900">
<span>مانده پس از ثبت این گزارش:</span>
<b className={`font-black ${isOver ? "text-rose-600" : "text-emerald-700"}`}>
{isOver ? `+${overAmt} ${item.unit} (مازاد سقف)` : `${rem} ${item.unit}`}
<div className="mt-2 pt-1.5 border-t border-amber-200/60 flex items-center justify-between text-[11px] text-amber-900">
<span>مجموع پس از ثبت:</span>
<b className="font-black text-slate-800">
{totC} {item.unit}
</b>
</div>
)}
</div>
{/* ۴. نوار پیشرفت مصرف به سقف */}
{alloc > 0 && (
<div className="space-y-1 pt-0.5">
<div className="flex items-center justify-between text-[9.5px] text-slate-500">
<div className="flex items-center justify-between text-[10px] text-slate-500">
<span>کل مصرف: <b className="text-slate-800">{totC}</b> از {alloc} {item.unit}</span>
<span className="font-bold">
{totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
@@ -757,14 +792,13 @@ export default function TaskDetailPage() {
</div>
)}
{/* ۵. هشدار اضافه مصرف جهت ثبت در سوابق و استعلام */}
{isOver && (
<p className="text-[10px] text-rose-700 font-medium flex items-center gap-1 bg-rose-50 p-1.5 rounded-lg border border-rose-200">
<AlertTriangle size={12} className="shrink-0 text-rose-600" />
<div className="flex items-start gap-1.5 rounded-lg border border-rose-200 bg-rose-50 p-2 text-[11px] text-rose-700">
<AlertTriangle size={14} className="shrink-0 text-rose-600 mt-0.5" />
<span>
میزان مصرف ({totC} {item.unit}) از سقف تخصیص ({alloc}) فراتر رفته است. مازاد (+{overAmt} {item.unit}) در استعلام‌های آتی قابل رهگیری است.
میزان مصرف (<strong className="tabular-nums">{totC}</strong> {item.unit}) از سقف مصوب (<strong className="tabular-nums">{alloc}</strong> {item.unit}) بیشتر است. (<span dir="ltr" className="font-bold">+{overAmt}</span> {item.unit} مازاد)
</span>
</p>
</div>
)}
</div>
);
@@ -773,7 +807,7 @@ export default function TaskDetailPage() {
)}
</div>
{/* ۲. نیروی انسانی تحویل‌شده و حاضر در کارگاه */}
{/* نیروی انسانی */}
<div className="mt-3 rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-sky-950 flex items-center gap-1">
@@ -862,7 +896,6 @@ export default function TaskDetailPage() {
)}
</div>
{/* ۳. توضیحات و شرح گزارش روزانه کارگاه */}
<div className="mt-3 pt-2 border-t border-slate-200">
<label className="text-[10px] font-medium text-slate-600 block mb-1">
توضیحات و گزارش روزانه کارگاه (اختیاری)
@@ -903,7 +936,7 @@ export default function TaskDetailPage() {
)}
</SectionCard>
{/* کارت تاریخچه ثبت پیشرفت و مصرف منابع */}
{/* کارت تاریخچه ثبت پیشرفت */}
{task.progress_logs && task.progress_logs.length > 0 && (
<SectionCard title={`تاریخچه گزارش‌های پیشرفت و مصرف منابع (${task.progress_logs.length})`}>
<div className="space-y-2.5">
@@ -921,11 +954,10 @@ export default function TaskDetailPage() {
<div className="text-[10px] text-slate-500 flex items-center gap-1">
<Clock size={11} className="text-slate-400" />
<span>{relativeTime(log.created_at)}</span>
{log.logged_by && <span>• توسط {log.logged_by}</span>}
{log.logged_by && <span>توسط {log.logged_by}</span>}
</div>
</div>
{/* نیروی کار حاضر در این گزارش */}
{log.human_resources_used && log.human_resources_used.length > 0 && (
<div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span>
@@ -944,7 +976,6 @@ export default function TaskDetailPage() {
</div>
)}
{/* مصالح مصرفی در این گزارش */}
{log.materials_used && log.materials_used.length > 0 && (
<div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرف‌شده:</span>
@@ -972,7 +1003,6 @@ export default function TaskDetailPage() {
</div>
)}
{/* توضیحات در صورت وجود */}
{log.description && (
<div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed">
{log.description}
@@ -984,7 +1014,7 @@ export default function TaskDetailPage() {
</SectionCard>
)}
{/* کارت تاریخ‌ها — برنامه‌ریزی‌شده و واقعی */}
{/* کارت تاریخ‌های وظیفه */}
<SectionCard title="تاریخ‌های وظیفه">
<div className="space-y-3">
<div>
@@ -1001,9 +1031,17 @@ export default function TaskDetailPage() {
<JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" />
</div>
</div>
{isDateRangeInvalid && (
<div className="mt-2.5 flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<button
type="button"
disabled={datesMutation.isPending}
disabled={datesMutation.isPending || isDateRangeInvalid}
onClick={() => datesMutation.mutate()}
className={`${btnSecondary} mt-2.5 w-full`}
>
@@ -1063,8 +1101,6 @@ export default function TaskDetailPage() {
/>
</SectionCard>
{/* کارت ارتباطات */}
<SectionCard
title={
@@ -1215,7 +1251,6 @@ export default function TaskDetailPage() {
onUpdated={() => invalidateTask()}
/>
{/* مودال ویرایش منابع تخصیص‌یافته به وظیفه */}
{showEditResourcesModal && (
<Modal
open={showEditResourcesModal}
@@ -1248,7 +1283,6 @@ export default function TaskDetailPage() {
</Modal>
)}
{/* مودال افزودن رابطه وابستگی جدید */}
{showAddRelationModal && (
<Modal
open={showAddRelationModal}
@@ -1352,4 +1386,4 @@ export default function TaskDetailPage() {
)}
</>
);
}
}
+1 -1
View File
@@ -687,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 progress DOUBLE PRECISION DEFAULT 0.0;
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;
BIN
View File
Binary file not shown.