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
19 changed files with 1415 additions and 1950 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 -11
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, timedelta
from decimal import Decimal
from sqlalchemy import select
@@ -176,11 +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)
# جلوگیری از ثبت تاریخ در آینده
now_utc = datetime.now(timezone.utc)
if tx_date > now_utc + timedelta(minutes=5):
raise ValidationError("امکان ثبت تاریخ یا زمان در آینده وجود ندارد", field="transaction_date")
# STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT":
@@ -203,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)
@@ -244,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)
@@ -262,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)
@@ -297,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)
@@ -327,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
```
+99 -216
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,70 +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 maxJalaliParts = useMemo(() => {
if (!maxDateObj) return null;
return gregorianToJalali(maxDateObj.getFullYear(), maxDateObj.getMonth() + 1, maxDateObj.getDate());
}, [maxDateObj]);
const canGoNextMonth = useMemo(() => {
if (!maxJalaliParts) return true;
const [cy, cm] = curJ;
const [my, mm] = maxJalaliParts;
return cy < my || (cy === my && cm < mm);
}, [curJ, maxJalaliParts]);
const canGoNextYear = useMemo(() => {
if (!maxJalaliParts) return true;
return curJ[0] < maxJalaliParts[0];
}, [curJ, maxJalaliParts]);
const clampIfFuture = (iso: string | null): string | null => {
if (!iso || !maxDateObj) return iso;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
if (d.getTime() > maxDateObj.getTime()) {
return maxDateObj.toISOString();
}
return iso;
};
const getClampedTime = (h: number, m: number, jy: number, jm: number, jd: number): { h: number; m: number } => {
let validH = Math.min(23, Math.max(0, h));
let validM = Math.min(59, Math.max(0, m));
if (maxDateObj && maxDateIso) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (dayIso === maxDateIso) {
const maxH = maxDateObj.getHours();
const maxM = maxDateObj.getMinutes();
if (validH > maxH || (validH === maxH && validM > maxM)) {
validH = maxH;
validM = maxM;
}
}
}
return { h: validH, m: validM };
};
const [text, setText] = useState<string>(() => {
if (!value) return "";
const j = isoToJalaliStr(value);
@@ -169,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));
@@ -185,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})$/);
@@ -199,34 +147,27 @@ 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]) => {
let ny = jy;
let nm = jm;
if (dir === "m-1") {
if (!canGoNextMonth) return [jy, jm, 1];
nm = nm === 12 ? 1 : nm + 1;
}
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
if (dir === "y-1") {
if (!canGoNextYear) return [jy, jm, 1];
ny += 1;
}
if (dir === "y-1") ny += 1;
if (dir === "y+1") ny -= 1;
if (nm < 1) {
nm = 12;
@@ -236,27 +177,15 @@ export function JalaliDatePicker({
nm = 1;
ny += 1;
}
if (maxJalaliParts) {
if (ny > maxJalaliParts[0] || (ny === maxJalaliParts[0] && nm > maxJalaliParts[1])) {
ny = maxJalaliParts[0];
nm = maxJalaliParts[1];
}
}
return [ny, nm, 1];
});
};
const selectDay = (jy: number, jm: number, jd: number) => {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dayIso > maxDateIso) return;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const { h: validH, m: validM } = getClampedTime(currentH, currentM, jy, jm, jd);
setHourText(faNum(String(validH).padStart(2, "0")));
setMinuteText(faNum(String(validM).padStart(2, "0")));
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(
@@ -267,97 +196,80 @@ 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;
}
const n = parseInt(digits, 10);
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : digits;
const clamped = Math.min(23, Math.max(0, n));
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
setHourText(faNum(textToShow));
onChange(buildIso(jy, jm, jd, clampedH, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM));
setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(23, Math.max(0, n));
setHourText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
setHourText(faNum(String(clampedH).padStart(2, "0")));
onChange(buildIso(jy, jm, jd, clampedH, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM));
setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
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;
}
const n = parseInt(digits, 10);
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : digits;
const clamped = Math.min(59, Math.max(0, n));
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
setMinuteText(faNum(textToShow));
onChange(buildIso(jy, jm, jd, validH, clampedM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`);
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
};
const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(59, Math.max(0, n));
setMinuteText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
setMinuteText(faNum(String(clampedM).padStart(2, "0")));
onChange(buildIso(jy, jm, jd, validH, clampedM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
};
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
@@ -384,23 +296,20 @@ export function JalaliDatePicker({
}
const iso = jalaliStrToIso(v);
if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) {
const nums = String(v)
.split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
onChange(dOnly);
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
} else {
onChange(validIso.slice(0, 10));
onChange(iso.slice(0, 10));
}
} else {
onChange(validIso);
onChange(iso);
}
const j = isoToJalali(validIso);
const j = isoToJalali(iso);
if (j) setCurJ([j[0], j[1], j[2]]);
}
};
@@ -412,32 +321,27 @@ export function JalaliDatePicker({
}
const iso = jalaliStrToIso(text);
if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) {
const nums = String(text)
.split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
onChange(dOnly);
setText(isoToJalaliStr(dOnly));
} else {
const dOnly = validIso.slice(0, 10);
onChange(dOnly);
setText(isoToJalaliStr(dOnly));
onChange(iso.slice(0, 10));
setText(isoToJalaliStr(iso));
}
} else {
onChange(validIso);
const j = isoToJalaliStr(validIso);
const d = new Date(validIso);
onChange(iso);
const j = isoToJalaliStr(iso);
const d = new Date(iso);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
setHourText(faNum(hh));
setMinuteText(faNum(mm));
}
}
} else if (value) {
@@ -456,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}
@@ -464,77 +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"
disabled={!canGoNextMonth}
className={cn("rounded px-1.5 text-sm", canGoNextMonth ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextMonth && nav("m-1")}
title={canGoNextMonth ? "ماه بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
›
</button>
<button
type="button"
disabled={!canGoNextYear}
className={cn("rounded px-1.5 text-sm", canGoNextYear ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextYear && nav("y-1")}
title={canGoNextYear ? "سال بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
<button 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",
@@ -547,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>
@@ -592,4 +475,4 @@ export function JalaliDatePicker({
)}
</div>
);
}
}
-21
View File
@@ -115,27 +115,6 @@ export function isoToJalaliStr(iso: string | null | undefined): string {
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
}
// format an ISO string to "۱۴۰۳/۰۵/۱۲ ۱۴:۳۰"
export function isoToJalaliDateTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${toFa(hh)}:${toFa(mm)}`;
}
// format time only: "۱۴:۳۰"
export function isoToTimeStr(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${toFa(hh)}:${toFa(mm)}`;
}
// format with month name: "۱۸ مرداد ۱۴۰۴"
export function isoToJalaliLong(iso: string | null | undefined): string {
if (!iso) return "";
-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,35 +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];
if (!raw || raw.trim() === "") return false;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() <= Date.now() + 60000;
};
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
@@ -199,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();
};
@@ -464,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,
]);
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
@@ -531,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));
@@ -583,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));
@@ -619,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,
@@ -631,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);
@@ -646,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("هیچ ردیفی پر نشده است");
@@ -662,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("هیچ ردیف معتبری پر نشده است");
@@ -685,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,
@@ -719,13 +686,6 @@ export function ChangeInventoryPage() {
};
const doSubmit = () => {
const rawDate = sectionDates[type];
if (!rawDate || !rawDate.trim() || Number.isNaN(new Date(rawDate).getTime())) {
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است.`);
}
if (new Date(rawDate).getTime() > Date.now() + 60000) {
return toast.error("امکان ثبت تراکنش در تاریخ یا زمان آینده وجود ندارد.");
}
try {
if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch();
@@ -740,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);
}
@@ -986,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>
@@ -999,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>
)}
@@ -1185,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">
@@ -1212,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>
@@ -1264,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>
@@ -1351,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}
/>
)}
{/* دکمه ثبت — دسکتاپ */}
@@ -1690,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();
+25 -199
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown, ChevronUp, Clock } from "lucide-react";
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { useAuthStore } from "../../store/auth";
@@ -10,24 +10,8 @@ import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } from "../../lib/jalali";
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" />
پاک کردن فیلترها
@@ -508,6 +363,7 @@ export function InventoryPage() {
<tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" />
<th className="py-2.5 pe-3 font-semibold">متریال</th>
<th className="py-2.5 pe-3 font-semibold">دسته‌بندی</th>
<th className="py-2.5 pe-3 font-semibold">انبار</th>
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
<th className="py-2.5 pe-3 font-semibold">تعداد</th>
@@ -659,9 +515,8 @@ function InventoryMobileCard({
<span className="text-muted/60">—</span>
)}
</div>
<span className="shrink-0 tabular-nums flex items-center gap-1">
<Clock className="w-3 h-3 text-muted/70" />
<span>{isoToJalaliDateTime(g.updatedAt)}</span>
<span className="shrink-0 tabular-nums">
{isoToJalaliStr(g.updatedAt)}
</span>
</div>
</div>
@@ -699,23 +554,13 @@ function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, ti
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td>
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3 font-medium text-ink/80">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">
<div className="font-semibold text-ink">{isoToJalaliStr(g.updatedAt)}</div>
<div className="text-[10.5px] text-muted flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3 text-muted/70 shrink-0" />
<span>{isoToTimeStr(g.updatedAt)}</span>
</div>
</td>
<td className="py-2.5 text-center text-muted">
<span className="p-1 inline-flex items-center justify-center rounded hover:text-accent">
{isExp ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</span>
</td>
<td className="py-2.5 pe-3">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2.5 text-muted">🕘</td>
</tr>
{isExp && (
<tr className="border-b border-line/40 bg-accent-bg/10">
<td colSpan={8} className="p-3.5">{timeline}</td>
<td colSpan={9} className="p-3.5">{timeline}</td>
</tr>
)}
</>
@@ -747,29 +592,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id}
type="button"
onClick={() => setSelTx(sel ? null : t.id)}
className={`flex min-w-[135px] flex-col gap-1 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
style={{ borderTop: `3px solid ${meta.color}` }}
>
<div className="flex items-center justify-between gap-1">
<span className="text-[11px] font-bold" style={{ color: meta.color }}>
{meta.icon} {meta.label}
</span>
</div>
<span className="text-[12.5px] font-extrabold tabular-nums" style={{ color: meta.color }}>
{t.quantity_number > 0 ? "+" : ""}{faNum(t.quantity_number)} {unit}
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit}
</span>
<div className="flex items-center justify-between gap-1 text-[10px] text-muted border-t border-line/40 pt-1">
<span>مانده: <b className="tabular-nums font-semibold">{faNum(bal)}</b></span>
</div>
<div className="flex items-center gap-1 text-[9.5px] text-muted/90 tabular-nums">
<Clock className="w-2.5 h-2.5 text-muted shrink-0" />
<span>{isoToJalaliDateTime(t.transaction_date)}</span>
</div>
{t.delivered_to && (
<div className="text-[9.5px] text-ink/80 font-medium truncate max-w-[125px]" title={t.delivered_to}>
تحویل: {t.delivered_to}
</div>
)}
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span>
</button>
);
});
@@ -793,11 +623,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>زمان وقوع: <b className="text-ink font-bold">{isoToJalaliDateTime(t.transaction_date)}</b></span>
{t.delivered_to && <span>تحویل به: <b className="text-ink font-bold">{t.delivered_to}</b></span>}
{t.created_at && (
<span>زمان ثبت در سیستم: <b className="text-ink/80">{isoToJalaliDateTime(t.created_at)}</b></span>
)}
<span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span>
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<>
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
+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.