Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31300728b6 | ||
|
|
42fb314834 | ||
|
|
264c7cc906 |
@@ -1,359 +1,191 @@
|
||||
# ERP & PM Integrated Platform — Dockerized
|
||||
## سامانه یکپارچه تدارکات، انبارداری، مدیریت پروژه (PM) و ربات هوشمند
|
||||
|
||||
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
||||
مجموعه نرمافزاری یکپارچه و خودکفا (Self-contained) شامل تمامی سرویسها، ماژولها و تنظیمات زیرساختی مورد نیاز برای اجرا در محیط توسعه و عملیات:
|
||||
|
||||
Self-contained ship package: everything needed to run the stack is in this
|
||||
folder — no external files or sibling folders required.
|
||||
|
||||
| Service | Container | Tech | Exposed port |
|
||||
| سرویس | نام کانتینر | فناوری / کاربرد | پورت در دسترس |
|
||||
|---|---|---|---|
|
||||
| **postgres** | `procurement-postgres` | PostgreSQL 18 (one instance: `procurement` + `casdoor` DBs) | `5433` |
|
||||
| **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) |
|
||||
|
||||
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
```
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Layout
|
||||
## 🌐 معماری شبکه و هدایت ترافیک (Traefik Edge Routing)
|
||||
|
||||
```text
|
||||
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
|
||||
┌────────────────────────┐
|
||||
│ 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) │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 ساختار پوشهها و اجزای مخزن
|
||||
|
||||
```text
|
||||
ERP/
|
||||
├── docker-compose.yml ← پیکربندی اصلی اجرای کامل تمام سرویسهای استک
|
||||
├── docker-compose.prod.yml ← لایه تنظیمات عملیاتی (Let's Encrypt و دامنههای رسمی)
|
||||
├── docker-compose.coolify.yml ← پیکربندی سازگار با استقرار بر روی Coolify
|
||||
├── start.ps1 ← اسکریپت هلپر پاورشل برای ساخت و اجرا
|
||||
├── mc.cmd ← ابزار خط فرمان MinIO Client برای مدیریت باکتها
|
||||
├── README.md ← همین سند راهنما
|
||||
├── backend/ ← سرویس بکاند اصلی تدارکات و انبار (procurement-api)
|
||||
│ ├── app/
|
||||
│ │ ├── db/ ← اتصال دیتابیس و توابع راهاندازی اسکیمای خودکار (init_db.py)
|
||||
│ │ ├── models/ ← مدلهای پایگاهداده (procurement, inventory, catalog)
|
||||
│ │ ├── routers/ ← اندپوینتهای API v1 (quotes, inventory, users, drafts, ...)
|
||||
│ │ ├── schemas/ ← مدلهای اعتبارسنجی Pydantic
|
||||
│ │ ├── services/ ← منطق بیزینس (انبارداری، استعلامها، ذخیرهسازی، ...)
|
||||
│ │ ├── utils/ ← ابزارهای امنیتی، خطایابی، ریتلیمیت
|
||||
│ │ ├── auth.py ← تایید توکن Casdoor و تفکیک دسترسیها
|
||||
│ │ ├── config.py ← متغیرهای پیکربندی
|
||||
│ │ └── main.py ← اسمبلی برنامه FastAPI و میدلورها
|
||||
│ └── tools/ ← اسکریپتهای سیدینگ (seed_casdoor.py) و تستهای دود (Smoke)
|
||||
├── frontend/ ← وباپلیکیشن اصلی ERP (procurement-frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ ← کامپوننتهای مشترک، لایهبندی و سوییچر پنلها
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Admin/ ← پنل مدیریت سامانه و همگامسازی کاربران
|
||||
│ │ │ ├── Auth/ ← صفحه ورود و احراز هویت
|
||||
│ │ │ ├── Dashboard/ ← کارتابل استعلامها و تصمیمگیری خرید
|
||||
│ │ │ ├── Inventory/ ← انبارداری (موجودی، ورود کالا، انتقال کارگاهی، کنترل روزانه)
|
||||
│ │ │ ├── Organization/ ← ماتریس دسترسی پنلها و انتساب پروژهها
|
||||
│ │ │ ├── Projects/ ← مدیریت پروژههای عمرانی
|
||||
│ │ │ ├── Suppliers/ ← دستهبندی موضوعی و فهرست تأمینکنندگان
|
||||
│ │ │ ├── Units/ ← سیستم کاتالوگ متریالها، تامینکنندگان و ضرایب تبدیل
|
||||
│ │ │ └── Wizard/ ← ویزارد مرحلهای ثبت استعلام (با اتصال مستقیم به پیشنویس ربات)
|
||||
│ │ ├── store/ ← مدیریت وضعیت با Zustand
|
||||
│ │ └── App.tsx ← درخت روتها، گیتهای امنیتی پنلها و نقشها
|
||||
├── pm/ ← پلتفرم مدیریت پروژه و کنترل کیفیت (پلتفرم آرامش)
|
||||
│ ├── backend/ ← بکاند مستقل PM (FastAPI, CPM Engine, QC Service)
|
||||
│ ├── frontend/ ← فرانتاند PM (گانت چارت شمسی، WBS، ثبت عیوب و بازرسی)
|
||||
│ ├── sql/ ← اسکیما و اسکریپتهای اختصاصی پایگاه داده PM
|
||||
│ └── API_CONTRACT.md ← قرارداد کامل اندپوینتهای PM
|
||||
├── bot/ ← ایجنت هوشمند بله برای ثبت صوتی/متنی استعلامها
|
||||
│ ├── app/ ← کلاینت Bale، پردازشگر STT صوتی، استخراج NLU با DeepSeek
|
||||
│ ├── docs/SETUP.md ← راهنمای راهاندازی و اتصال توکن بله
|
||||
│ └── requirements.txt
|
||||
├── monitoring/ ← استک مانیتورینگ، متریکها و لاگها (پرومتئوس، گرافانا، لوکی)
|
||||
├── infra/ ← تنظیمات وبهوک دیپلوی خودکار و کانفیگهای داینامیک Traefik
|
||||
├── sql/
|
||||
│ ├── 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)
|
||||
|
||||
│ ├── migration.sql ← اسکریپت اولیه پایه دیتابیس
|
||||
│ └── 02-casdoor-db.sql ← اسکریپت راهاندازی دیتابیس هویت Casdoor
|
||||
└── tests/ ← تستهای یکپارچگی NLU و ویزارد استعلام
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Login
|
||||
## 🔐 سیستم احراز هویت، ماتریس پنلها و کنترل دسترسی (RBAC)
|
||||
|
||||
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.
|
||||
سامانه از سیستم احراز هویت **Casdoor OIDC** بهره میبرد؛ ورود به صورت In-Page بوده و توکن JWT صادر شده توسط بکاند با JWKS اعتبارسنجی میشود.
|
||||
|
||||
* **مدیر پروژه (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.
|
||||
مدل دسترسی سامانه بر پایه **پنلها (`user_panels`)** بازطراحی شده است:
|
||||
|
||||
**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).
|
||||
### ۱. لایههای دسترسی پنلها
|
||||
* **پنل تدارکات (`procurement`):**
|
||||
* `full`: دسترسی کامل به کارتابل استعلامها، تایید یا رد پیشنهادها، تامینکنندگان و قیمتها.
|
||||
* `limited`: فقط امکان ثبت استعلام جدید در ویزارد برای پروژههای منتسب به کاربر (کاربر پس از ورود مستقیماً به `/wizard` یا `/requester` هدایت میشود).
|
||||
* **پنل انبارداری (`inventory`):**
|
||||
* `full`: مشاهده موجودی همه انبارها، رهگیری «کالاهای در راه» و کارتابل تایید رسید.
|
||||
* `limited`: فقط مشاهده و مدیریت انبارهای پروژههای تحت عضویت، همراه با دسترسی به ثبت تغییر موجودی کارگاهی (`/inventory/change`).
|
||||
* **پنل مدیریت پروژه (`pm`):**
|
||||
* دسترسی به مشاهده یا ویرایش تسکها، چارت گانت، ثبت عیوب و فرمهای QC در `/panel/pm`.
|
||||
|
||||
| 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).
|
||||
### ۲. قوانین و نقش سرپرست پروژه (`supervisor`)
|
||||
* کاربرانی با نقش `supervisor` تنها در صورتی مجاز به ورود به سامانه هستند که حداقل یک پروژه در جدول `user_project_members` یا `projects.pm_user_id` به آنها منتسب شده باشد. در غیر این صورت، پیام عدم تخصیص پروژه نمایش داده شده و اجازه ورود نخواهند داشت.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Working with the deployed system (server)
|
||||
## 🚀 راهنمای سریع راهاندازی محیط توسعه (Docker)
|
||||
|
||||
The production deployment of this stack runs on the server and is reachable
|
||||
at these public addresses (HTTPS):
|
||||
### پیشنیازها
|
||||
* Docker Desktop (ویندوز) یا Docker Engine + Docker Compose Plugin (لینوکس)
|
||||
* حداقل ۴ گیگابایت فضای خالی دیسک و اتصال اینترنت برای بارگیری ایمیجهای اولیه
|
||||
|
||||
| What | Address |
|
||||
| --- | --- |
|
||||
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
|
||||
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
|
||||
|
||||
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||
assigned inside the app (`users.role` — see «Login» above and
|
||||
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
|
||||
|
||||
### MinIO console on the server (SSH tunnel)
|
||||
|
||||
The MinIO console is deliberately **loopback-only** on the server
|
||||
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
|
||||
is an SSH tunnel from your machine:
|
||||
|
||||
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||
use the panel):
|
||||
```powershell
|
||||
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||
|
||||
```
|
||||
|
||||
|
||||
You will also land in a normal SSH session — that is fine; the tunnel
|
||||
lives as long as the window stays open.
|
||||
2. In your browser, open: **http://localhost:9001**
|
||||
3. Log in with the production MinIO root credentials from the server's
|
||||
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||
**never** written in this repo or its history. See
|
||||
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
|
||||
|
||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||
ERP stores, browsable and downloadable from there.
|
||||
|
||||
⚠️ Those credentials are **full admin access to the file storage**. They live
|
||||
only in the server-side `.env`; if one ever leaks, rotate it immediately
|
||||
(checklist in `docs/SECRET-MANAGEMENT.md`).
|
||||
|
||||
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
|
||||
tunnel.
|
||||
|
||||
Notes from the server setup:
|
||||
|
||||
* The panel is published only on the server's loopback, so the internet
|
||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||
* Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||
why the console uses `9001`.
|
||||
* If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||
with a password prompt on top), that can be arranged on request — the
|
||||
tunnel remains the safer default.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (target machine)
|
||||
|
||||
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||
* Internet access on first build (image + `npm ci` downloads)
|
||||
|
||||
---
|
||||
|
||||
## Start the whole stack
|
||||
### دستورات اجرا
|
||||
|
||||
```powershell
|
||||
cd <this-folder>
|
||||
# ۱. رفتن به پوشه پروژه و ساخت/اجرای کانتینرها
|
||||
docker compose up -d --build
|
||||
|
||||
```
|
||||
|
||||
First run downloads base images and builds the frontend (takes a few minutes).
|
||||
Subsequent runs: `docker compose up -d`.
|
||||
|
||||
Wait until all services are up:
|
||||
|
||||
```powershell
|
||||
# ۲. بررسی وضعیت سلامت سرویسها
|
||||
docker compose ps
|
||||
|
||||
```
|
||||
|
||||
All should show `Up` (postgres also `(healthy)`).
|
||||
تمام سرویسها باید وضعیت `Up` و دیتابیس وضعیت `(healthy)` را نشان دهند.
|
||||
|
||||
### درگاهها و آدرسهای محلی
|
||||
|
||||
| بخش | آدرس محلی | توضیحات |
|
||||
|---|---|---|
|
||||
| **رابط کاربری ERP** | http://localhost | داشبورد اصلی سامانه |
|
||||
| **پلتفرم PM** | http://localhost/pm | مدیریت پروژه و کنترل کیفیت (قابل دسترس در `/panel/pm`) |
|
||||
| **مستندات API (Swagger)** | http://localhost:8000/docs | مستندات تعاملی اندپوینتهای تدارکات |
|
||||
| **سلامت API** | http://localhost:8000/health/ready | بررسی اتصال دیتابیس، Casdoor و MinIO |
|
||||
| **کنسول MinIO** | http://localhost:9001 | نام کاربری و رمز پیشفرض: `minioadmin` / `minioadmin` |
|
||||
| **کنسول Casdoor** | http://localhost:8080 | نام کاربری و رمز اولیه: `admin` / `123` |
|
||||
| **پایگاه داده PostgreSQL** | `localhost:5455` | نام دیتابیس: `procurement`، کاربر: `postgres` |
|
||||
|
||||
---
|
||||
|
||||
## Verify
|
||||
## 🤖 ربات هوشمند بله (`procurement-bot`)
|
||||
|
||||
| What | Where |
|
||||
| --- | --- |
|
||||
| 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).
|
||||
ربات پیامرسان بله به عنوان یک سرویس اختصاصی درون شبکه فعال است:
|
||||
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) قرار دارد.
|
||||
|
||||
---
|
||||
|
||||
## MinIO (object storage for invoice / proforma files)
|
||||
## 🔄 انتقال بینکارگاهی کالاها (`project_transfers`)
|
||||
|
||||
| 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
|
||||
|
||||
```
|
||||
سامانه دارای سیستم ردیابی انتقال متریال بین کارگاههاست:
|
||||
* ثبت انتقال از طریق صفحه «تغییر موجودی» با نوع `انتقال بین کارگاهی`.
|
||||
* وضعیتهای انتقال: `در حال انتقال (in_transit)`، `تحویلشده (received)` و `لغوشده (cancelled)`.
|
||||
* کارگاه مقصد از طریق زبانه «انتقالات ورودی» در صفحه «کالاهای در راه» اقلام ارسالی را دریافت و به انبارهای خود تخصیص میدهد.
|
||||
|
||||
---
|
||||
|
||||
## Database credentials / connection
|
||||
## 🛠️ عملیات نگهداری و دیپلوی در سرور (Operations)
|
||||
|
||||
```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`.
|
||||
|
||||
```
|
||||
* **دیپلوی خودکار:** سرور با هر 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) سازماندهی شدهاند.
|
||||
|
||||
@@ -217,6 +217,7 @@ 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,
|
||||
}
|
||||
|
||||
@@ -461,6 +462,9 @@ 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")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -175,6 +176,11 @@ 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":
|
||||
@@ -197,7 +203,7 @@ async def apply_transaction(
|
||||
recorded_qty=recorded,
|
||||
physical_qty=payload.physical_qty,
|
||||
performed_by=performed_by,
|
||||
transaction_date=payload.transaction_date,
|
||||
transaction_date=tx_date,
|
||||
note=payload.note,
|
||||
)
|
||||
db.add(tx)
|
||||
@@ -238,7 +244,7 @@ async def apply_transaction(
|
||||
quantity=-quantity,
|
||||
transfer_pair_id=pair_id,
|
||||
performed_by=performed_by,
|
||||
transaction_date=payload.transaction_date,
|
||||
transaction_date=tx_date,
|
||||
note=payload.note,
|
||||
)
|
||||
db.add(out_tx)
|
||||
@@ -256,7 +262,7 @@ async def apply_transaction(
|
||||
quantity=quantity,
|
||||
transfer_pair_id=pair_id,
|
||||
performed_by=performed_by,
|
||||
transaction_date=payload.transaction_date,
|
||||
transaction_date=tx_date,
|
||||
note=payload.note,
|
||||
)
|
||||
db.add(in_tx)
|
||||
@@ -291,7 +297,7 @@ async def apply_transaction(
|
||||
quantity=quantity,
|
||||
performed_by=performed_by,
|
||||
delivered_to=payload.delivered_to,
|
||||
transaction_date=payload.transaction_date,
|
||||
transaction_date=tx_date,
|
||||
note=payload.note,
|
||||
)
|
||||
db.add(tx)
|
||||
@@ -321,7 +327,7 @@ async def apply_transaction(
|
||||
quantity=-quantity,
|
||||
performed_by=performed_by,
|
||||
delivered_to=payload.delivered_to,
|
||||
transaction_date=payload.transaction_date,
|
||||
transaction_date=tx_date,
|
||||
note=payload.note,
|
||||
)
|
||||
db.add(tx)
|
||||
|
||||
+54
-104
@@ -1,120 +1,70 @@
|
||||
# procurement_ai_agent — Bale chatbot for the procurement ERP
|
||||
# procurement_ai_agent — ایجنت هوشمند بله برای سامانه ERP
|
||||
|
||||
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
|
||||
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.
|
||||
بات پیامرسان بله (Bale Messenger Bot) که پیامهای صوتی و متنی فارسی کاربران را دریافت کرده و با استفاده از مدلهای هوش مصنوعی (DeepSeek NLU + Whisper STT) آنها را به اقدامات و پیشنویسهای استعلام در سامانه ERP تبدیل میکند.
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
این ایجنت **به صورت سرویس بومی (`procurement-bot`) مستقیماً درون `docker-compose.yml` ریشه پروژه** تعریف شده است و نیازی به نصب یا راهاندازی دستی ندارد.
|
||||
|
||||
* **نوع اتصال:** Long-polling از سرور بله (`tapi.bale.ai`) — بدون نیاز به باز کردن پورت خارجی، وبهوک یا TLS.
|
||||
* **ارتباط با بکاند:** به صورت مستقیم و درونشبکهای با سرویس `api` در آدرس `http://api:8000/api/v1`.
|
||||
* **پایگاه داده:** استفاده از جدول `bot_drafts` در پایگاهداده مشترک PostgreSQL.
|
||||
|
||||
```text
|
||||
کاربر (بله: متن یا صوت)
|
||||
│ getUpdates (Long-polling)
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ ERP api (/api/v1) │
|
||||
│ quotes · suppliers · materials│
|
||||
└─────────────────────────────────┘
|
||||
┌─────────────────────────────────┐
|
||||
│ procurement_ai_agent │
|
||||
│ ffmpeg → STT تبدیل صوت به متن │
|
||||
│ LLM → استخراج نیت و موجودیتها │
|
||||
│ Fuzzy Match → انطباق با ERP │
|
||||
│ ایجاد پیشنویس در bot_drafts │
|
||||
└────────────────┬────────────────┘
|
||||
│
|
||||
├── ثبت نهایی از بله با دکمه تایید
|
||||
└── یا باز کردن مستقیم در ویزارد وب:
|
||||
/wizard?draft=<UUID>
|
||||
```
|
||||
|
||||
## What it can do
|
||||
---
|
||||
|
||||
| Activity | Example (voice or text) | Committed via |
|
||||
## 🌟 قابلیتها و کاربردها
|
||||
|
||||
| فعالیت | نمونه پیام (صوت یا متن) | اقدام نهایی |
|
||||
|---|---|---|
|
||||
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
|
||||
| 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` |
|
||||
| **ثبت استعلام جدید** | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
|
||||
| **تعریف تأمینکننده** | «تأمینکننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
|
||||
| **تعریف متریال جدید** | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
|
||||
| **استعلام وضعیت** | «استعلامهای در انتظار؟» / «کدامها بهزودی منقضی میشوند؟» | `GET /quotes` |
|
||||
| **پذیرش استعلام** | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
|
||||
|
||||
### The draft → approve 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.
|
||||
## 🔗 حلقه پیشنویس و تایید (Draft-First Loop)
|
||||
|
||||
### Team access & attribution
|
||||
1. کاربر پیام صوتی یا متنی میفرستد. صوت به متن تبدیل شده و مدل هوش مصنوعی موجودیتها را استخراج میکند.
|
||||
2. اسامی تامینکننده، متریال، پروژه و واحدها با کاتالوگ ERP تطبیق داده میشوند.
|
||||
3. کارت مرور پیشنویس همراه با فیلدهای مشخصشده و اقلام خالی («چه چیزهایی ناقص است») برای کاربر ارسال میشود.
|
||||
4. **دو روش برای تکمیل و ثبت نهایی وجود دارد:**
|
||||
* **از طریق چت بله:** با ارسال پیامهای اصلاحی و در نهایت لمس دکمه «✅ ثبت نهایی».
|
||||
* **از طریق پنل وب ویزارد:** با کلیک روی لینک مستقیم `/wizard?draft=<UUID>` که اطلاعات به صورت خودکار در فرم ۵ مرحلهای ویزارد بارگذاری میشود و کاربر میتواند فایل فاکتور را نیز ضمیمه کند.
|
||||
|
||||
Each member links their Bale chat once: `/start` → Casdoor username → password
|
||||
(validated via the ERP's real `POST /auth/login`, used once, never stored).
|
||||
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
|
||||
records the **human** who asked.
|
||||
---
|
||||
|
||||
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink`
|
||||
## ⚙️ متغیرهای محیطی و پیکربندی
|
||||
|
||||
## Quick start (Docker)
|
||||
تنظیمات بات از طریق فایل `.env` در ریشه پروژه خوانده میشود:
|
||||
|
||||
```bash
|
||||
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
|
||||
```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=شناسه_عددی_مدیران_در_بله
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
راهنمای گامبهگام اتصال و عیبیابی در [docs/SETUP.md](docs/SETUP.md) در دسترس است.
|
||||
|
||||
+1
-1
@@ -302,7 +302,7 @@ services:
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- "traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) || Host(`192.168.100.8`)"
|
||||
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`)
|
||||
- traefik.http.routers.frontend.entrypoints=web
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=80
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
value: string | null;
|
||||
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||
onChange: (val: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
withTime?: boolean;
|
||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||
disabled?: boolean;
|
||||
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
|
||||
}
|
||||
|
||||
function todayJalali(): [number, number, number] {
|
||||
@@ -42,16 +43,20 @@ 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() };
|
||||
@@ -64,6 +69,7 @@ function timeOf(iso: string | null): { h: number; m: number } {
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -71,14 +77,70 @@ 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);
|
||||
@@ -107,6 +169,7 @@ 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));
|
||||
@@ -122,18 +185,7 @@ export function JalaliDatePicker({
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
|
||||
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);
|
||||
};
|
||||
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
@@ -147,27 +199,34 @@ 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;
|
||||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||||
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 }[] = [];
|
||||
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
|
||||
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 });
|
||||
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")}`;
|
||||
cells.push({ d, iso, isToday: iso === tIso });
|
||||
const isDisabled = !!(maxDateIso && iso > maxDateIso);
|
||||
cells.push({ d, iso, isToday: iso === tIso, disabled: isDisabled });
|
||||
}
|
||||
return cells;
|
||||
}, [curJ]);
|
||||
}, [curJ, maxDateIso]);
|
||||
|
||||
const nav = (dir: string) => {
|
||||
setCurJ(([jy, jm]) => {
|
||||
let ny = jy;
|
||||
let nm = jm;
|
||||
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
|
||||
if (dir === "m-1") {
|
||||
if (!canGoNextMonth) return [jy, jm, 1];
|
||||
nm = nm === 12 ? 1 : nm + 1;
|
||||
}
|
||||
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
|
||||
if (dir === "y-1") ny += 1;
|
||||
if (dir === "y-1") {
|
||||
if (!canGoNextYear) return [jy, jm, 1];
|
||||
ny += 1;
|
||||
}
|
||||
if (dir === "y+1") ny -= 1;
|
||||
if (nm < 1) {
|
||||
nm = 12;
|
||||
@@ -177,15 +236,27 @@ 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 validH = Math.min(23, Math.max(0, currentH));
|
||||
const validM = Math.min(59, Math.max(0, currentM));
|
||||
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 val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
@@ -196,80 +267,97 @@ 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 clamped = Math.min(23, Math.max(0, n));
|
||||
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
|
||||
setHourText(faNum(textToShow));
|
||||
|
||||
const [jy, jm, jd] = curJ;
|
||||
const 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 { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
|
||||
const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : 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 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 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 { 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 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 clamped = Math.min(59, Math.max(0, n));
|
||||
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
|
||||
setMinuteText(faNum(textToShow));
|
||||
|
||||
const [jy, jm, jd] = curJ;
|
||||
const 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 { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
|
||||
const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : 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 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 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 { 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 pickToday = () => {
|
||||
const now = new Date();
|
||||
const [jy, jm, jd] = todayJalali();
|
||||
const nh = now.getHours();
|
||||
const nm = now.getMinutes();
|
||||
const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd);
|
||||
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);
|
||||
onChange(val);
|
||||
setText(
|
||||
withTime
|
||||
@@ -296,20 +384,23 @@ 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]);
|
||||
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||
let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
|
||||
onChange(dOnly);
|
||||
} else {
|
||||
onChange(iso.slice(0, 10));
|
||||
onChange(validIso.slice(0, 10));
|
||||
}
|
||||
} else {
|
||||
onChange(iso);
|
||||
onChange(validIso);
|
||||
}
|
||||
const j = isoToJalali(iso);
|
||||
const j = isoToJalali(validIso);
|
||||
if (j) setCurJ([j[0], j[1], j[2]]);
|
||||
}
|
||||
};
|
||||
@@ -321,27 +412,32 @@ 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]);
|
||||
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
|
||||
onChange(dOnly);
|
||||
setText(isoToJalaliStr(dOnly));
|
||||
} else {
|
||||
onChange(iso.slice(0, 10));
|
||||
setText(isoToJalaliStr(iso));
|
||||
const dOnly = validIso.slice(0, 10);
|
||||
onChange(dOnly);
|
||||
setText(isoToJalaliStr(dOnly));
|
||||
}
|
||||
} else {
|
||||
onChange(iso);
|
||||
const j = isoToJalaliStr(iso);
|
||||
const d = new Date(iso);
|
||||
onChange(validIso);
|
||||
const j = isoToJalaliStr(validIso);
|
||||
const d = new Date(validIso);
|
||||
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) {
|
||||
@@ -360,7 +456,7 @@ export function JalaliDatePicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative w-full", className)}>
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
@@ -368,57 +464,77 @@ export function JalaliDatePicker({
|
||||
value={text}
|
||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||
autoComplete="off"
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onFocus={() => !disabled && setOpen(true)}
|
||||
onClick={() => !disabled && setOpen(true)}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
className={cn(
|
||||
// پاپآپ مطلق که بر اساس کلاس تراز تنظیم میشود
|
||||
"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)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{/* 1. Header: Month / Year Navigation */}
|
||||
<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")}>
|
||||
<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")}>
|
||||
«
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-sm font-bold">
|
||||
<span className="text-[13px] font-bold">
|
||||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||||
</span>
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoNextMonth}
|
||||
className={cn("rounded px-1.5 text-sm", canGoNextMonth ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
|
||||
onClick={() => canGoNextMonth && nav("m-1")}
|
||||
title={canGoNextMonth ? "ماه بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoNextYear}
|
||||
className={cn("rounded px-1.5 text-sm", canGoNextYear ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
|
||||
onClick={() => canGoNextYear && nav("y-1")}
|
||||
title={canGoNextYear ? "سال بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Day of Week Header & Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="py-1 text-[11px] font-bold text-muted">
|
||||
<div key={w} className="py-0.5 text-[10px] 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-lg py-2 text-[13px] hover:bg-slate-bg transition-colors",
|
||||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
|
||||
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",
|
||||
@@ -431,43 +547,44 @@ export function JalaliDatePicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Time Inputs Row */}
|
||||
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||
{withTime && (
|
||||
<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="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
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"
|
||||
value={minuteText}
|
||||
inputMode="numeric"
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||
onBlur={handleMinuteBlur}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-muted">دقیقه</span>
|
||||
<span className="text-[11.5px] 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-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"
|
||||
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"
|
||||
value={hourText}
|
||||
inputMode="numeric"
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleHourChange(e.target.value)}
|
||||
onBlur={handleHourBlur}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-muted">ساعت</span>
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 4. Action Buttons Footer */}
|
||||
<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}>
|
||||
<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}>
|
||||
امروز
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
پاک کردن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,27 @@ export function isoToJalaliStr(iso: string | null | undefined): string {
|
||||
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
|
||||
}
|
||||
|
||||
// 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 "";
|
||||
|
||||
@@ -172,6 +172,7 @@ 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,7 +81,35 @@ export function ChangeInventoryPage() {
|
||||
const [matTxt, setMatTxt] = useState("");
|
||||
const [brandTxt, setBrandTxt] = useState("");
|
||||
const [qty, setQty] = useState("");
|
||||
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
||||
const [sectionDates, setSectionDates] = useState<Record<FormType, string>>(() => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
PURCHASE: now,
|
||||
WITHDRAWAL: now,
|
||||
PROJECT_TRANSFER: now,
|
||||
RETURN: now,
|
||||
TRANSFER: now,
|
||||
TRANSFER_IN: now,
|
||||
TRANSFER_OUT: now,
|
||||
DAMAGE: now,
|
||||
WASTE: now,
|
||||
LOSS: now,
|
||||
FOUND: now,
|
||||
STOCK_COUNT: now,
|
||||
};
|
||||
});
|
||||
|
||||
const setSectionDate = (t: FormType, val: string | null) => {
|
||||
setSectionDates((prev) => ({ ...prev, [t]: val ?? "" }));
|
||||
};
|
||||
|
||||
const isSectionDateValid = (t: FormType): boolean => {
|
||||
const raw = sectionDates[t];
|
||||
if (!raw || raw.trim() === "") return false;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
return d.getTime() <= Date.now() + 60000;
|
||||
};
|
||||
const [note, setNote] = useState("");
|
||||
const [deliveredTo, setDeliveredTo] = useState("");
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
@@ -171,10 +199,13 @@ export function ChangeInventoryPage() {
|
||||
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
|
||||
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
|
||||
|
||||
const toServerIso = () => {
|
||||
const toServerIso = (t: FormType) => {
|
||||
try {
|
||||
const d = new Date(dateInput);
|
||||
const raw = sectionDates[t];
|
||||
if (raw) {
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
}
|
||||
} catch { /* نادیده */ }
|
||||
return new Date().toISOString();
|
||||
};
|
||||
@@ -433,13 +464,15 @@ 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(),
|
||||
transaction_date: toServerIso("PURCHASE"),
|
||||
};
|
||||
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: new Date().toTimeString().slice(0, 5), whName: whName(warehouses, whId) },
|
||||
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: pTime, whName: whName(warehouses, whId) },
|
||||
...prev,
|
||||
]);
|
||||
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
|
||||
@@ -498,7 +531,7 @@ export function ChangeInventoryPage() {
|
||||
unit_id: w.unit?.id ?? null,
|
||||
specs: w.specs || {},
|
||||
note: buildReturnNote(retNote[w.id], w.id),
|
||||
transaction_date: new Date().toISOString(),
|
||||
transaction_date: toServerIso("RETURN"),
|
||||
});
|
||||
okCount++;
|
||||
setRetDone((prev) => new Set(prev).add(w.id));
|
||||
@@ -550,7 +583,7 @@ export function ChangeInventoryPage() {
|
||||
quantity: parseNum(ptQty[r.id]),
|
||||
specs: r.specs || {},
|
||||
note,
|
||||
transfer_date: new Date().toISOString(),
|
||||
transfer_date: toServerIso("PROJECT_TRANSFER"),
|
||||
}, user?.project_id);
|
||||
ok++;
|
||||
setPtSucceeded((prev) => new Set(prev).add(r.id));
|
||||
@@ -586,7 +619,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() };
|
||||
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: t,
|
||||
@@ -598,7 +631,7 @@ export function ChangeInventoryPage() {
|
||||
specs: rec.specs || {},
|
||||
note: r.note,
|
||||
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
|
||||
transaction_date: toServerIso(),
|
||||
transaction_date: toServerIso(t),
|
||||
};
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
@@ -613,7 +646,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() };
|
||||
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso("TRANSFER") };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
@@ -629,7 +662,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: new Date().toISOString() };
|
||||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso("FOUND") };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
|
||||
@@ -652,7 +685,7 @@ export function ChangeInventoryPage() {
|
||||
brand_id: rec.brand?.id || null,
|
||||
physical_qty: n,
|
||||
note: r.note,
|
||||
transaction_date: toServerIso(),
|
||||
transaction_date: toServerIso("STOCK_COUNT"),
|
||||
});
|
||||
confirm.push({
|
||||
material: rec.material.name,
|
||||
@@ -686,6 +719,13 @@ 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();
|
||||
@@ -700,6 +740,7 @@ export function ChangeInventoryPage() {
|
||||
};
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
if (!isSectionDateValid(type)) return false;
|
||||
if (type === "PURCHASE") {
|
||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0);
|
||||
}
|
||||
@@ -945,21 +986,7 @@ export function ChangeInventoryPage() {
|
||||
</div>
|
||||
|
||||
{type === "WITHDRAWAL" && (
|
||||
<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="grid gap-3 sm:grid-cols-3">
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">
|
||||
تحویل به (تحویلگیرنده) <span className="text-rose-500 font-bold">*</span>
|
||||
@@ -972,10 +999,18 @@ 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>
|
||||
)}
|
||||
|
||||
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">جستجو در متریالها (اختیاری)</label>
|
||||
<input
|
||||
@@ -990,13 +1025,89 @@ export function ChangeInventoryPage() {
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */}
|
||||
{/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
|
||||
{isTransfer && (
|
||||
<div className="field mb-3">
|
||||
<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"
|
||||
@@ -1010,6 +1121,7 @@ export function ChangeInventoryPage() {
|
||||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------- موجودی اولیه: فرم ثبت پیوسته ---------- */}
|
||||
@@ -1073,12 +1185,15 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
|
||||
<label className="text-xs font-semibold text-muted">
|
||||
تاریخ و زمان (شمسی) <span className="text-rose-500 font-bold">*</span>
|
||||
</label>
|
||||
<JalaliDatePicker
|
||||
value={dateInput}
|
||||
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
||||
placeholder="۱۴۰۳/۰۵/۱۲"
|
||||
value={sectionDates.PURCHASE}
|
||||
onChange={(iso) => setSectionDate("PURCHASE", iso)}
|
||||
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
|
||||
withTime
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
@@ -1097,9 +1212,22 @@ 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
|
||||
@@ -1110,6 +1238,7 @@ export function ChangeInventoryPage() {
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">جستجو در متریالها (اختیاری)</label>
|
||||
<input
|
||||
@@ -1135,11 +1264,24 @@ 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>
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">بازهی روز برداشتها (فیلتر جستجو)</label>
|
||||
<ReturnFilterBar
|
||||
preset={retPreset}
|
||||
onPreset={(p) => setRetPreset(p)}
|
||||
@@ -1149,6 +1291,7 @@ export function ChangeInventoryPage() {
|
||||
onCustomTo={(iso) => setRetCustomTo(iso)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">جستجو در متریالها (اختیاری)</label>
|
||||
<input
|
||||
@@ -1208,6 +1351,19 @@ 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}
|
||||
@@ -1216,6 +1372,7 @@ export function ChangeInventoryPage() {
|
||||
emptyMsg={foundEmptyMsg}
|
||||
loading={foundLoading && !pendingLosses.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* دکمه ثبت — دسکتاپ */}
|
||||
@@ -1533,7 +1690,7 @@ export function ChangeInventoryPage() {
|
||||
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود."
|
||||
: type === "RETURN"
|
||||
? "برگشت، دفتر را تغییر نمیدهد؛ تراکنش جدیدی ثبت میشود و مانده قابل برگشت هر برداشت بهروز میماند."
|
||||
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
|
||||
: `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || 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()];
|
||||
return [...gmap.values()].sort((a, b) => new Date(b.last).getTime() - new Date(a.last).getTime());
|
||||
}, [withdrawals]);
|
||||
|
||||
const isToday = sel === todayKey();
|
||||
|
||||
@@ -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 } from "lucide-react";
|
||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown, ChevronUp, Clock } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
@@ -10,8 +10,24 @@ 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 } from "../../lib/jalali";
|
||||
import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } 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 }> = {
|
||||
@@ -58,10 +74,20 @@ export function InventoryPage() {
|
||||
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||
const [filters, setFilters] = useState<{
|
||||
warehouse: string;
|
||||
material: string;
|
||||
project: string;
|
||||
transactionType: string;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
}>({
|
||||
warehouse: params.get("warehouse") || "",
|
||||
material: "",
|
||||
project: "",
|
||||
transactionType: "",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
});
|
||||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
@@ -77,14 +103,40 @@ 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) {
|
||||
@@ -104,7 +156,6 @@ 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)) {
|
||||
@@ -114,26 +165,61 @@ 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]);
|
||||
}, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
|
||||
|
||||
const groupTxs = (g: Group) =>
|
||||
transactions
|
||||
filteredTransactions
|
||||
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.transaction_date).getTime();
|
||||
const db = new Date(b.transaction_date).getTime();
|
||||
if (da !== db) return da - db;
|
||||
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return ca - cb;
|
||||
});
|
||||
|
||||
const brandTxs = (g: Group, bid: string) =>
|
||||
transactions
|
||||
filteredTransactions
|
||||
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.transaction_date).getTime();
|
||||
const db = new Date(b.transaction_date).getTime();
|
||||
if (da !== db) return da - db;
|
||||
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return ca - cb;
|
||||
});
|
||||
|
||||
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||
const hasFilter = !!(
|
||||
filters.warehouse ||
|
||||
(isAdmin && filters.project) ||
|
||||
filters.material ||
|
||||
filters.transactionType ||
|
||||
filters.dateFrom ||
|
||||
filters.dateTo
|
||||
);
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (!user) {
|
||||
@@ -285,13 +371,72 @@ 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"
|
||||
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
|
||||
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 })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
پاک کردن فیلترها
|
||||
@@ -363,7 +508,6 @@ export function InventoryPage() {
|
||||
<tr className="border-b border-line bg-bg/40 text-right text-muted">
|
||||
<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>
|
||||
@@ -515,8 +659,9 @@ function InventoryMobileCard({
|
||||
<span className="text-muted/60">—</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{isoToJalaliStr(g.updatedAt)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,13 +699,23 @@ function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, ti
|
||||
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
||||
</td>
|
||||
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
|
||||
<td className="py-2.5 pe-3">{unit}</td>
|
||||
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
|
||||
<td className="py-2.5 text-muted">🕘</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>
|
||||
</tr>
|
||||
{isExp && (
|
||||
<tr className="border-b border-line/40 bg-accent-bg/10">
|
||||
<td colSpan={9} className="p-3.5">{timeline}</td>
|
||||
<td colSpan={8} className="p-3.5">{timeline}</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
@@ -592,14 +747,29 @@ 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-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
|
||||
className={`flex min-w-[135px] flex-col gap-1 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
|
||||
style={{ borderTop: `3px solid ${meta.color}` }}
|
||||
>
|
||||
<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}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>
|
||||
{meta.icon} {meta.label}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</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>
|
||||
<div className="flex items-center justify-between gap-1 text-[10px] text-muted border-t border-line/40 pt-1">
|
||||
<span>مانده: <b className="tabular-nums font-semibold">{faNum(bal)}</b></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9.5px] text-muted/90 tabular-nums">
|
||||
<Clock className="w-2.5 h-2.5 text-muted shrink-0" />
|
||||
<span>{isoToJalaliDateTime(t.transaction_date)}</span>
|
||||
</div>
|
||||
{t.delivered_to && (
|
||||
<div className="text-[9.5px] text-ink/80 font-medium truncate max-w-[125px]" title={t.delivered_to}>
|
||||
تحویل: {t.delivered_to}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -623,7 +793,11 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
||||
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
|
||||
<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">{isoToJalaliStr(t.transaction_date)}</b></span>
|
||||
<span>زمان وقوع: <b className="text-ink font-bold">{isoToJalaliDateTime(t.transaction_date)}</b></span>
|
||||
{t.delivered_to && <span>تحویل به: <b className="text-ink font-bold">{t.delivered_to}</b></span>}
|
||||
{t.created_at && (
|
||||
<span>زمان ثبت در سیستم: <b className="text-ink/80">{isoToJalaliDateTime(t.created_at)}</b></span>
|
||||
)}
|
||||
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
|
||||
<>
|
||||
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||
|
||||
+35
-67
@@ -1,91 +1,59 @@
|
||||
# PM — سامانه یکپارچه مدیریت پروژههای ساخت و کنترل کیفیت
|
||||
# PM — سامانه یکپارچه مدیریت پروژههای ساخت و کنترل کیفیت (Aramesh PM)
|
||||
|
||||
اپلیکیشن موبایلمحور و تحت وب برای مدیریت جامع پروژههای عمرانی و ساخت: مدیریت سلسلهمراتب فعالیتها (WBS)، زمانبندی و گانت چارت، مسیر بحرانی (CPM)، سیستم پیشرفته کنترل کیفیت (QC)، مدیریت مصالح و انبار (با اتصال به ERP) و داشبورد عملیاتی روزانه.
|
||||
پلتفرم تخصصی مدیریت پروژههای عمرانی، کنترل کیفیت و زمانبندی کارگاهی که به صورت وندور شده و یکپارچه در کنار سامانه ERP قرار دارد.
|
||||
|
||||
---
|
||||
|
||||
## دسترسی سریع
|
||||
## 🏗️ نحوه استقرار و دسترسی در سامانه ERP
|
||||
|
||||
| سامانه | آدرس شبکه محلی | آدرس محلی سیستم |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
پلتفرم PM در استک یکپارچه پروژه از دو سرویس داکر تشکیل شده است:
|
||||
* **`pm-backend` (`pm-api`):** وبسرویس پایتون مبتنی بر FastAPI روی شبکه داخلی استک.
|
||||
* **`pm-frontend` (`pm-frontend`):** اپلیکیشن کلاینت React با سرور داخلی Nginx که از طریق ریورسپروکسی Traefik زیر مسیر `/pm` قرار گرفته است.
|
||||
|
||||
**ورود پیشفرض:** `admin` / `pm123456` (قابل تغییر از طریق فایل `.env`)
|
||||
### راههای دسترسی:
|
||||
1. **از داخل رابط کاربری ERP:** با ورود به حساب کاربری (نقشهای مجاز یا پنل PM) و انتخاب منوی **«مدیریت پروژه (PM)»** در آدرس `/panel/pm` (به صورت آیفریم روان با سشن یکپارچه).
|
||||
2. **دسترسی مستقیم مرورگر:** در آدرس `http://localhost/pm` (یا دامنه عملیاتی با پسوند `/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 مستقیماً به همان دیتابیس `procurement` در کانتینر Postgres متصل میشود. جداول اختصاصی PM (`pm_projects`, `pm_tasks`, `pm_qc_*`, `pm_defects`, ...) در زمان بوت اولیه به صورت خودکار و بدون تداخل با جداول تدارکات ایجاد میشوند (`schema_sync.py`).
|
||||
* **احراز هویت یکپارچه (SSO):** ورود با همان حساب کاربری Casdoor انجام شده و سطح دسترسی پنل PM (`user_panels.pm`) کنترل میشود.
|
||||
|
||||
---
|
||||
|
||||
## مالکیت داده
|
||||
## ⚙️ قابلیتهای اصلی پلتفرم PM
|
||||
|
||||
| حوزه داده | متولی و مالک | توضیحات |
|
||||
|---|---|---|
|
||||
| پروژهها، فعالیتها، وابستگیها، مایلستونها | **PM Platform** | مدیریت کامل در دیتابیس بومی PM |
|
||||
| چکلیستها و کنترل کیفیت (QC) | **PM Platform** | پیش از اجرا، حین اجرا و پس از اجرا |
|
||||
| ثبت عیوب و اقدامات اصلاحی | **PM Platform** | الصاق تصویر، شدت عیب، پیگیری رفع |
|
||||
| کاتالوگ مصالح و موجودی واقعی انبار | **ERP** | استعلام برخط موجودی از طریق API |
|
||||
| رزرو مصالح و درخواستهای خرید | **PM Platform** | رزرو نرم بدون دستکاری انبار تا زمان تأیید |
|
||||
### ۱. مدیریت زمانبندی و شکست کار (WBS & CPM Engine)
|
||||
* ساختار شکست کار با عمق نامحدود (فاز، بخش، فعالیت، زیرفعالیت).
|
||||
* روابط وابستگی چهارگانه بین فعالیتها (`FS`, `SS`, `FF`, `SF`) به همراه زمان وقفه (Lag).
|
||||
* محاسبه خودکار مسیر بحرانی (Critical Path Method) و شناوری کل فعالیتها.
|
||||
* گانت چارت شمسی و تعریف مایلستونهای کلیدی پروژه.
|
||||
|
||||
### ۲. چرخه سهمرحلهای کنترل کیفیت (QC Module)
|
||||
* **قالبهای عمومی بازرسی (QC Templates):** تعریف چکلیستهای استاندارد کیفیت برای انواع فعالیتهای ساختمانی.
|
||||
* **مراحل ارزیابی:**
|
||||
* **پیش از اجرا (`pre`):** دروازه ورود به تسک؛ تا زمان تایید این مرحله، پیشرفت تسک نمیتواند بیشتر از ۰٪ شود.
|
||||
* **حین اجرا (`during`):** ثبت بازرسیهای فنی حین عملیات اجرایی.
|
||||
* **پس از اجرا (`after`):** کنترل نهایی؛ تا زمان تایید تمامی سوالات الزامی، پیشرفت تسک اجازه ۱۰۰٪ شدن ندارد.
|
||||
* **ثبت و پیگیری عیوب (Defects):** ثبت عیوب همراه با تصاویر، سطح بحرانی بودن و لزوم بازرسی مجدد پس از اقدامات اصلاحی.
|
||||
|
||||
### ۳. مدیریت منابع و اتصال به انبار کارگاه
|
||||
* امکان تخصیص متریال و ثبت رزرو نرم با استعلام موجودی واقعی انبارهای متناظر در ERP.
|
||||
|
||||
---
|
||||
|
||||
## قابلیتهای کلیدی
|
||||
|
||||
### ۱. مدیریت زمانبندی و گانت چارت (CPM Engine)
|
||||
- ایجاد پروژهها با کدهای اختصاصی و تیم پروژه
|
||||
- ساختار شکست کار (WBS) در عمقهای نامحدود (فاز، بخش، فعالیت، زیرفعالیت)
|
||||
- وابستگیهای چهارگانه بین فعالیتها (`FS`, `SS`, `FF`, `SF`) همراه با روزهای تأخیر (Lag)
|
||||
- محاسبه خودکار مسیر بحرانی (Critical Path) و شناوری کل فعالیتها
|
||||
- تعریف مایلستونها و نقاط عطف کلیدی پروژه
|
||||
|
||||
### ۲. چرخه سهمرحلهای کنترل کیفیت (QC)
|
||||
- **مراحل ارزیابی:** پیش از اجرا (`pre`) · حین اجرا (`during`) · پس از اجرا (`after`)
|
||||
- **قوانین دروازهای سرور:**
|
||||
- فعالیت تا زمان تأیید الزامات پیش از اجرا اجازه شروع (پیشرفت > ۰) ندارد.
|
||||
- فعالیت تا زمان پاس شدن همه الزامات الزامی اجازه رسیدن به ۱۰۰٪ ندارد.
|
||||
- ثبت عیوب همراه با تصاویر و الزام به بازرسی مجدد پس از اصلاح.
|
||||
|
||||
### ۳. مدیریت منابع و ارتباط با ERP
|
||||
- تخصیص نفرات، ماشینآلات و مصالح به تفکیک فعالیت
|
||||
- اتصال پروژه به پروژه متناظر در ERP جهت استعلام موجودی انبارهای کارگاه
|
||||
- رزرو نرم مصالح جهت جلوگیری از تداخل مصرف در بخشهای مختلف پروژه
|
||||
|
||||
### ۴. تقویم و تاریخهای شمسی
|
||||
- تقویم بومی جلالی بدون خطا و با رعایت روزهای کاری
|
||||
- ثبت و نمایش روان تاریخها در رابط کاربری به زبان فارسی
|
||||
|
||||
---
|
||||
|
||||
## راهاندازی و اجرا
|
||||
|
||||
اجرای آسان با اسکریپت پاورشل یا داکر کامپوز:
|
||||
## 🧪 اجرای آزمایشی مستقل (Standalone Dev)
|
||||
|
||||
در صورت تمایل به اجرای مستقل پلتفرم PM برای تستهای ایزوله:
|
||||
```powershell
|
||||
# اجرای خودکار از پوشه PM:
|
||||
# از درون پوشه pm:
|
||||
powershell -ExecutionPolicy Bypass -File .\start.ps1
|
||||
```
|
||||
|
||||
یا مستقیماً با داکر:
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
این حالت روی پورت محلی `8083` به صورت ایزوله اجرا خواهد شد.
|
||||
|
||||
---
|
||||
|
||||
## قرارداد API
|
||||
مستندات کامل اندپوینتها: [`API_CONTRACT.md`](API_CONTRACT.md)
|
||||
## 📑 قرارداد و مستندات API
|
||||
قرارداد جامع اندپوینتها و نمونه پیامها در فایل [API_CONTRACT.md](API_CONTRACT.md) و راهنمای تفصیلی ساختار جداول در [DATABASE_GUIDE.md](DATABASE_GUIDE.md) مستند شده است.
|
||||
|
||||
+38
-8
@@ -1,4 +1,24 @@
|
||||
# PM — Construction Project Management Platform
|
||||
services:
|
||||
pm-postgres:
|
||||
image: postgres:18
|
||||
container_name: pm-postgres
|
||||
environment:
|
||||
POSTGRES_DB: pm
|
||||
POSTGRES_USER: pm
|
||||
POSTGRES_PASSWORD: ${PM_DB_PASSWORD:-pm_secret_2026}
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- pm_postgres_data:/var/lib/postgresql
|
||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pm -d pm"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
pm-api:
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -11,19 +31,24 @@ services:
|
||||
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
||||
PM_FILES_DIR: /data/files
|
||||
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
||||
# ERP integration (read-only service account)
|
||||
PM_ERP_BASE_URL: http://procurement-api:8000
|
||||
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
|
||||
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
||||
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
||||
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
||||
# Casdoor SSO — همان نشست ERP؛ پیشفرض لوکال، origin کسدور لوکال است
|
||||
PM_CASDOOR_ISSUER: ${PM_CASDOOR_ISSUER:-http://localhost:8080}
|
||||
volumes:
|
||||
- pm_files:/data/files
|
||||
- ./backend/app:/srv/pm/app
|
||||
depends_on:
|
||||
pm-postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
- erp_network
|
||||
- erp_default
|
||||
restart: unless-stopped
|
||||
|
||||
pm-frontend:
|
||||
@@ -35,25 +60,30 @@ services:
|
||||
- "8083:80"
|
||||
depends_on:
|
||||
- pm-api
|
||||
# پنل «مدیریت پروژه» روی همان دامنه ERP زیر مسیر /pm سرو میشود —
|
||||
# Traefikِ استک ERP این کانتینر را با قاعده PathPrefix کشف میکند
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.pm.rule=PathPrefix(`/pm`)"
|
||||
- "traefik.http.routers.pm.entrypoints=web"
|
||||
- "traefik.http.routers.pm.priority=100"
|
||||
- "traefik.http.services.pm.loadbalancer.server.port=80"
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
|
||||
- traefik.http.routers.pm.entrypoints=web
|
||||
# اولویت بالاتر از روتر Host(`localhost`) فرانت ERP — وگرنه /pm/api
|
||||
# ممکن است به SPA فرانت ERP برسد (POST → 405)
|
||||
- traefik.http.routers.pm.priority=100
|
||||
- traefik.http.services.pm.loadbalancer.server.port=80
|
||||
networks:
|
||||
- default
|
||||
- traefik_network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pm_postgres_data:
|
||||
pm_files:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: pm_default
|
||||
erp_network:
|
||||
name: all-together_default
|
||||
erp_default:
|
||||
external: true
|
||||
traefik_network:
|
||||
external: true
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Link2, Plus, Trash2, Users } from "lucide-react";
|
||||
import { AlertTriangle, Link2, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createTaskRelation,
|
||||
@@ -24,110 +24,6 @@ import { isoToApiDate } from "../lib/jalali";
|
||||
import { reconcileDates } from "../lib/dates";
|
||||
import { formatDate } from "../lib/format";
|
||||
|
||||
export function HumanResourceInputs({
|
||||
humanResources,
|
||||
setHumanResources,
|
||||
}: {
|
||||
humanResources: HumanResourceItem[];
|
||||
setHumanResources: React.Dispatch<React.SetStateAction<HumanResourceItem[]>>;
|
||||
}) {
|
||||
const handleAdd = () => {
|
||||
setHumanResources([...humanResources, { role: "", count: 1 }]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
setHumanResources(humanResources.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, field: "role" | "count", val: string | number) => {
|
||||
const next = [...humanResources];
|
||||
if (field === "role") {
|
||||
next[index] = { ...next[index], role: String(val) };
|
||||
} else {
|
||||
next[index] = { ...next[index], count: Number(val) || 0 };
|
||||
}
|
||||
setHumanResources(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="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.5">
|
||||
<Users size={14} className="text-sky-600" />
|
||||
<span>نیروی انسانی مورد نیاز</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="text-[10px] font-semibold text-sky-700 hover:text-sky-900 bg-white border border-sky-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>افزودن ردیف نیرو</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{humanResources.length === 0 ? (
|
||||
<p className="text-center py-2 text-xs text-slate-400">
|
||||
ردیفی برای نیروی انسانی تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{humanResources.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-lg border border-slate-200/80 bg-white p-2.5 text-xs shadow-2xs flex items-center gap-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-semibold text-slate-500 block mb-1">
|
||||
نقش / رده شغلی
|
||||
</label>
|
||||
<input
|
||||
value={item.role}
|
||||
onChange={(e) => handleChange(idx, "role", e.target.value)}
|
||||
placeholder="مثلاً: آرماتوربند، کارگر ساده..."
|
||||
className="h-8 w-full rounded-lg border border-slate-200 px-2.5 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-24 shrink-0">
|
||||
<label className="text-[10px] font-semibold text-slate-500 block mb-1">
|
||||
تعداد درخواستی
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.count ? item.count : ""}
|
||||
onChange={(e) =>
|
||||
handleChange(idx, "count", e.target.value ? parseInt(e.target.value) : 0)
|
||||
}
|
||||
placeholder="نفر"
|
||||
className="h-8 w-full rounded-lg border border-slate-200 px-2 pe-7 text-xs text-center font-bold text-slate-800 focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<span className="absolute left-2 text-[10px] text-slate-400 pointer-events-none">
|
||||
نفر
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(idx)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف ردیف"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskEditModalProps {
|
||||
open: boolean;
|
||||
task: Task | TaskDetail | null;
|
||||
@@ -191,15 +87,6 @@ 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 || "");
|
||||
@@ -225,7 +112,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);
|
||||
@@ -235,11 +122,12 @@ 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),
|
||||
@@ -273,7 +161,7 @@ export function TaskEditModal({
|
||||
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
|
||||
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,9 +179,12 @@ 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);
|
||||
}
|
||||
@@ -341,6 +232,7 @@ export function TaskEditModal({
|
||||
const startIso = isoToApiDate(plannedStart);
|
||||
const endIso = isoToApiDate(plannedEnd);
|
||||
|
||||
// ۱. اجباری بودن تاریخ شروع و پایان
|
||||
if (!startIso) {
|
||||
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
|
||||
return;
|
||||
@@ -350,11 +242,13 @@ 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)
|
||||
@@ -387,6 +281,7 @@ export function TaskEditModal({
|
||||
}
|
||||
}
|
||||
|
||||
// ۳. محدودیت نسبت به فرزندان موجود
|
||||
const children = allTasks.filter((t) => {
|
||||
const p = t.parent_id
|
||||
? String(t.parent_id)
|
||||
@@ -548,6 +443,7 @@ 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">
|
||||
@@ -587,14 +483,6 @@ 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">
|
||||
@@ -662,11 +550,7 @@ export function TaskEditModal({
|
||||
setMaterials={setMaterials}
|
||||
/>
|
||||
|
||||
<HumanResourceInputs
|
||||
humanResources={humanResources}
|
||||
setHumanResources={setHumanResources}
|
||||
/>
|
||||
|
||||
{/* بخش روابط پیشنیازی و وابستگیها (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">
|
||||
@@ -680,6 +564,7 @@ export function TaskEditModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* لیست وابستگیهای موجود */}
|
||||
<div className="space-y-1.5">
|
||||
{currentTaskRelations.length === 0 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
@@ -734,6 +619,7 @@ 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">
|
||||
@@ -837,7 +723,7 @@ export function TaskEditModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || isDateRangeInvalid}
|
||||
disabled={updateMutation.isPending}
|
||||
className={btnPrimary}
|
||||
>
|
||||
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState } 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("");
|
||||
|
||||
// دریافت اطلاعات QC وظیفه
|
||||
// Load Task QC Data
|
||||
const qcQuery = useQuery({
|
||||
queryKey: ["task-qc", taskId],
|
||||
queryFn: () => getTaskQc(taskId),
|
||||
@@ -66,50 +66,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
||||
const questions = definitions.flatMap((d) => d.questions);
|
||||
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
|
||||
|
||||
// محاسبه زنده و دقیق آمار هر مرحله بر اساس لیست واقعی سوالات
|
||||
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)
|
||||
// Mutations
|
||||
const invalidateQc = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
||||
@@ -136,6 +93,7 @@ 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 });
|
||||
@@ -200,15 +158,18 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
||||
},
|
||||
});
|
||||
|
||||
// محاسبه پیشرفت کل چکلیست
|
||||
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));
|
||||
// Calculate Progress
|
||||
const totalQuestions = readiness?.all.total ?? questions.length;
|
||||
const pendingCount = readiness?.all.pending ?? 0;
|
||||
const answeredCount = totalQuestions - pendingCount;
|
||||
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. بنر وضعیت آمادگی شروع و اتمام کار */}
|
||||
@@ -245,13 +206,12 @@ 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>
|
||||
@@ -292,72 +252,52 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ۳ تب فازها با عرض مساوی و شمارنده دقیق */}
|
||||
<div className="grid grid-cols-3 gap-1 rounded-lg bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
|
||||
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */}
|
||||
<div className="flex rounded-lg bg-slate-200/80 p-0.5 text-xs font-semibold text-slate-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActivePhase("pre")}
|
||||
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
|
||||
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
|
||||
activePhase === "pre"
|
||||
? "bg-white text-amber-800 shadow-sm font-bold"
|
||||
: "text-slate-600 hover:text-slate-800"
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActivePhase("during")}
|
||||
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
|
||||
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
|
||||
activePhase === "during"
|
||||
? "bg-white text-sky-800 shadow-sm font-bold"
|
||||
: "text-slate-600 hover:text-slate-800"
|
||||
}`}
|
||||
>
|
||||
<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>۲. حین اجرا</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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActivePhase("after")}
|
||||
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
|
||||
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
|
||||
activePhase === "after"
|
||||
? "bg-white text-emerald-800 shadow-sm font-bold"
|
||||
: "text-slate-600 hover:text-slate-800"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<span>۳. تحویل و پس از اجرا</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -423,11 +363,12 @@ 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}
|
||||
@@ -442,6 +383,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
||||
<span>قبول</span>
|
||||
</button>
|
||||
|
||||
{/* دکمه رد / Fail */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canExecuteQc || answerMutation.isPending}
|
||||
@@ -456,6 +398,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
||||
<span>مردود</span>
|
||||
</button>
|
||||
|
||||
{/* دکمه مطرح نیست */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canExecuteQc || answerMutation.isPending}
|
||||
@@ -472,7 +415,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">
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "../lib/api";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { visibleTabs, getRoleTitle } from "../lib/roles";
|
||||
import { visibleTabs } from "../lib/roles";
|
||||
|
||||
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
|
||||
|
||||
@@ -69,27 +69,28 @@ export function PageHeader({
|
||||
}) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const fullName = user?.display_name || user?.username || "کاربر";
|
||||
const role = getRoleTitle(user?.role, user?.role_label);
|
||||
const userSub = user ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500 font-normal">
|
||||
<span className="font-semibold text-slate-700">{fullName}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="font-medium text-sky-600">{role}</span>
|
||||
</span>
|
||||
) : null;
|
||||
const username = user?.username || "-";
|
||||
const role =
|
||||
user?.role_label ||
|
||||
((user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor"
|
||||
? "سرپرست پروژه"
|
||||
: (user?.role as string) === "admin"
|
||||
? "مدیر ارشد"
|
||||
: "مدیر پروژه");
|
||||
const userSub = user ? `${fullName} | نام کاربری: ${username} | نقش: ${role}` : null;
|
||||
const displaySub = sub !== undefined ? sub : userSub;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200/80 bg-white/95 px-2.5 py-1.5 backdrop-blur shadow-xs">
|
||||
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200 bg-white/95 px-2 py-1.5 backdrop-blur">
|
||||
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="بازگشت"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
@@ -113,26 +114,13 @@ const ERP_URL =
|
||||
type PanelMenuItem =
|
||||
| { kind: "divider" }
|
||||
| { kind: "link"; label: string; href: string }
|
||||
| {
|
||||
kind: "current";
|
||||
label: string;
|
||||
children?: { label: string; active: boolean; onClick: () => void }[];
|
||||
}
|
||||
| { kind: "current"; label: string }
|
||||
| { kind: "button"; label: string; onClick: () => void };
|
||||
|
||||
export function PanelsMenu({
|
||||
projects,
|
||||
activeProjectId,
|
||||
onSelectProject,
|
||||
}: {
|
||||
projects?: { id?: string | number; op_project_id?: number; name: string }[];
|
||||
activeProjectId?: string | number;
|
||||
onSelectProject?: (id: string) => void;
|
||||
} = {}) {
|
||||
export function PanelsMenu() {
|
||||
const navigate = useNavigate();
|
||||
const clear = useAuthStore((s) => s.clear);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -206,23 +194,7 @@ export function PanelsMenu({
|
||||
}
|
||||
|
||||
if (hasPm) {
|
||||
const projectChildren =
|
||||
projects && projects.length > 0
|
||||
? projects.map((p) => {
|
||||
const pId = String(p.id ?? p.op_project_id ?? "");
|
||||
return {
|
||||
label: p.name,
|
||||
active: String(activeProjectId) === pId,
|
||||
onClick: () => onSelectProject?.(pId),
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
|
||||
items.push({
|
||||
kind: "current",
|
||||
label: "مدیریت پروژه",
|
||||
children: projectChildren,
|
||||
});
|
||||
items.push({ kind: "current", label: "مدیریت پروژه" });
|
||||
}
|
||||
|
||||
if (canManageOrg) {
|
||||
@@ -239,7 +211,7 @@ export function PanelsMenu({
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label="منو"
|
||||
title="منو"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl text-slate-600 hover:bg-slate-100 active:scale-95 transition-all"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
||||
>
|
||||
{open ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
@@ -268,52 +240,13 @@ export function PanelsMenu({
|
||||
{it.label}
|
||||
</a>
|
||||
) : it.kind === "current" ? (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white shadow-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{it.label}</span>
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white"
|
||||
>
|
||||
{it.label}
|
||||
<span className="text-[10px] opacity-80">✓ فعلی</span>
|
||||
</div>
|
||||
{it.children && it.children.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? "بستن فهرست پروژهها" : "نمایش پروژهها"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((ex) => !ex);
|
||||
}}
|
||||
className="inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-white hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{it.children && it.children.length > 0 && expanded && (
|
||||
<div className="mt-1 flex flex-col gap-0.5 border-s-2 border-slate-200 ps-2 ms-3">
|
||||
{it.children.map((c, j) => (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
c.onClick();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] text-start transition-colors ${
|
||||
c.active
|
||||
? "bg-sky-50 font-bold text-sky-700"
|
||||
: "text-slate-700 hover:bg-slate-50 active:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{c.label}</span>
|
||||
{c.active && <span className="shrink-0 text-[10px] font-bold">✓ فعلی</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
@@ -342,30 +275,23 @@ export function TabBar() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const tabs = visibleTabs(user);
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200/80 bg-white/95 pb-[env(safe-area-inset-bottom)] shadow-[0_-4px_16px_rgba(0,0,0,0.04)] backdrop-blur-md">
|
||||
<div className="flex h-14 items-center justify-around px-2">
|
||||
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200 bg-white/95 pb-[env(safe-area-inset-bottom)] backdrop-blur">
|
||||
<div className="flex h-16 items-stretch">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex flex-1 flex-col items-center justify-center py-1 transition-all duration-200 active:scale-95 ${
|
||||
isActive ? "text-sky-600 font-bold" : "text-slate-400 hover:text-slate-600 font-medium"
|
||||
`flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors ${
|
||||
isActive ? "text-sky-600" : "text-slate-400"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{/* Soft active indicator background pill & glowing top indicator */}
|
||||
{isActive && (
|
||||
<>
|
||||
<span className="absolute inset-x-1.5 inset-y-0.5 -z-10 rounded-xl bg-sky-50 shadow-xs border border-sky-100/70" />
|
||||
<span className="absolute -top-1.5 h-1 w-5 rounded-full bg-sky-600 shadow-[0_0_8px_rgba(2,132,199,0.55)]" />
|
||||
</>
|
||||
)}
|
||||
<tab.icon size={20} strokeWidth={isActive ? 2.4 : 1.7} className="transition-transform group-hover:scale-105" />
|
||||
<span className="text-[10px] leading-tight mt-0.5">{tab.label}</span>
|
||||
<tab.icon size={22} strokeWidth={isActive ? 2.3 : 1.8} />
|
||||
<span>{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -375,71 +301,6 @@ export function TabBar() {
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// کنترل سگمنتد تبها (Segmented Control)
|
||||
// ----------------------------------------
|
||||
|
||||
export interface SegmentedOption<T extends string = string> {
|
||||
id: T;
|
||||
label: string;
|
||||
count?: number;
|
||||
tone?: "red" | "amber" | "green" | "sky" | "slate";
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
}: {
|
||||
options: SegmentedOption<T>[];
|
||||
value: T;
|
||||
onChange: (val: T) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={`flex items-center rounded-xl bg-slate-100/90 p-1 text-xs font-medium text-slate-500 shadow-inner ${className}`}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = opt.id === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(opt.id)}
|
||||
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 text-center text-xs font-semibold transition-all duration-150 select-none ${
|
||||
active
|
||||
? "bg-white text-slate-900 shadow-sm border border-slate-200/60"
|
||||
: "text-slate-500 hover:text-slate-800 active:bg-slate-200/50"
|
||||
}`}
|
||||
>
|
||||
{opt.icon && <opt.icon size={13} className={active ? "text-sky-600" : "text-slate-400"} />}
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{opt.count !== undefined && opt.count > 0 && (
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.2 text-[10px] font-bold tabular-nums leading-none ${
|
||||
opt.tone === "red"
|
||||
? active ? "bg-red-100 text-red-700" : "bg-red-50 text-red-600"
|
||||
: opt.tone === "amber"
|
||||
? active ? "bg-amber-100 text-amber-700" : "bg-amber-50 text-amber-600"
|
||||
: active ? "bg-sky-100 text-sky-700" : "bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{opt.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// کارت و چیپ
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -209,10 +209,8 @@ export type DefectSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface DashboardTask {
|
||||
id?: string;
|
||||
project_id?: string;
|
||||
op_wp_id?: number | null;
|
||||
subject: string;
|
||||
description?: string;
|
||||
due_date: string | null;
|
||||
percentage_done: number;
|
||||
project_name: string;
|
||||
@@ -250,9 +248,6 @@ export interface DashboardToday {
|
||||
export interface ProjectStats {
|
||||
total: number;
|
||||
active: number;
|
||||
completed?: number;
|
||||
in_progress?: number;
|
||||
blocked?: number;
|
||||
awaiting_qc: number;
|
||||
failed_qc: number;
|
||||
overdue: number;
|
||||
|
||||
@@ -34,61 +34,9 @@ export type Permission = (typeof Perm)[keyof typeof Perm];
|
||||
|
||||
export const roleLabels: Record<Role, string> = {
|
||||
project_manager: "مدیر پروژه",
|
||||
project_supervisor: "سرپرست کارگاه",
|
||||
project_supervisor: "سرپرست پروژه",
|
||||
};
|
||||
|
||||
/**
|
||||
* نگاشت تمیز کدهای نقش به عناوین فارسی استاندارد سیستم:
|
||||
* - site_supervisor / project_supervisor / supervisor -> "سرپرست کارگاه"
|
||||
* - project_manager / pm -> "مدیر پروژه"
|
||||
* - admin / ceo -> "مدیر ارشد"
|
||||
*/
|
||||
export function getRoleTitle(role?: string | null, customLabel?: string | null): string {
|
||||
const normalized = (role ?? "").toLowerCase().trim();
|
||||
switch (normalized) {
|
||||
case "site_supervisor":
|
||||
case "project_supervisor":
|
||||
case "supervisor":
|
||||
return "سرپرست کارگاه";
|
||||
case "project_manager":
|
||||
case "pm":
|
||||
return "مدیر پروژه";
|
||||
case "admin":
|
||||
case "ceo":
|
||||
case "administrator":
|
||||
return "مدیر ارشد";
|
||||
case "procurement_manager":
|
||||
return "مدیر تدارکات";
|
||||
}
|
||||
|
||||
if (customLabel && customLabel.trim()) {
|
||||
if (customLabel.trim() === "سرپرست پروژه") {
|
||||
return "سرپرست کارگاه";
|
||||
}
|
||||
return customLabel.trim();
|
||||
}
|
||||
|
||||
if (!role) return "کاربر سیستم";
|
||||
return roleLabels[normalized as Role] || "کاربر سیستم";
|
||||
}
|
||||
|
||||
/**
|
||||
* تعیین عنوان پویای بالای هدر صفحه بر اساس نقش کاربر:
|
||||
* - سرپرست کارگاه -> "میز کار سرپرست کارگاه"
|
||||
* - مدیر پروژه / سایر -> "مدیریت پروژه"
|
||||
*/
|
||||
export function getDashboardHeaderTitle(role?: string | null): string {
|
||||
const normalized = (role ?? "").toLowerCase().trim();
|
||||
if (
|
||||
normalized === "site_supervisor" ||
|
||||
normalized === "project_supervisor" ||
|
||||
normalized === "supervisor"
|
||||
) {
|
||||
return "میز کار سرپرست کارگاه";
|
||||
}
|
||||
return "مدیریت پروژه";
|
||||
}
|
||||
|
||||
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
|
||||
return user?.permissions?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -16,15 +15,21 @@ 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";
|
||||
@@ -33,6 +38,7 @@ import type {
|
||||
CreateTaskPayload,
|
||||
HumanResourceItem,
|
||||
MaterialItem,
|
||||
ProjectMember,
|
||||
Task,
|
||||
TaskRelation,
|
||||
UpdateRelationPayload,
|
||||
@@ -44,6 +50,7 @@ import {
|
||||
Chip,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
InitialAvatar,
|
||||
Modal,
|
||||
PageHeader,
|
||||
ProgressBar,
|
||||
@@ -53,7 +60,7 @@ import {
|
||||
} from "../components/ui";
|
||||
import { JalaliDatePicker } from "../components/JalaliDatePicker";
|
||||
import { GanttChart } from "../components/GanttChart";
|
||||
import { TaskEditModal, HumanResourceInputs } from "../components/TaskEditModal";
|
||||
import { TaskEditModal } from "../components/TaskEditModal";
|
||||
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
|
||||
import { isoToApiDate } from "../lib/jalali";
|
||||
import { reconcileDates } from "../lib/dates";
|
||||
@@ -135,6 +142,7 @@ 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";
|
||||
|
||||
@@ -163,6 +171,7 @@ 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>
|
||||
@@ -251,6 +260,7 @@ 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;
|
||||
@@ -262,16 +272,7 @@ 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 | "">("");
|
||||
|
||||
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
|
||||
const isDateRangeInvalid = Boolean(
|
||||
plannedStart &&
|
||||
plannedEnd &&
|
||||
isoToApiDate(plannedStart) &&
|
||||
isoToApiDate(plannedEnd) &&
|
||||
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
|
||||
);
|
||||
const [relLag, setRelLag] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -279,7 +280,7 @@ function TaskCreateModal({
|
||||
setSelectedChildIds([]);
|
||||
setDraftRelations([]);
|
||||
setRelTargetId("");
|
||||
setRelLag("");
|
||||
setRelLag(0);
|
||||
setErrorMsg("");
|
||||
}
|
||||
}, [open, parentTask]);
|
||||
@@ -289,6 +290,7 @@ 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 {
|
||||
@@ -299,6 +301,7 @@ function TaskCreateModal({
|
||||
}
|
||||
}
|
||||
|
||||
// ۲. ایجاد روابط وابستگی تعریف شده (FS, SS, FF, SF)
|
||||
if (draftRelations.length > 0 && newTaskId) {
|
||||
for (const rel of draftRelations) {
|
||||
try {
|
||||
@@ -353,6 +356,7 @@ function TaskCreateModal({
|
||||
const startIso = isoToApiDate(plannedStart);
|
||||
const endIso = isoToApiDate(plannedEnd);
|
||||
|
||||
// ۱. اجباری بودن تاریخ شروع و پایان
|
||||
if (!startIso) {
|
||||
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
|
||||
return;
|
||||
@@ -362,11 +366,13 @@ function TaskCreateModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
|
||||
if (startIso > endIso) {
|
||||
setErrorMsg("تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد");
|
||||
setErrorMsg("تاریخ شروع وظیفه نمیتواند بعد از تاریخ اتمام آن باشد");
|
||||
return;
|
||||
}
|
||||
|
||||
// ۳. انطباق با تاریخهای والد انتخابی
|
||||
const activeParent = selectedParentId
|
||||
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
|
||||
: null;
|
||||
@@ -413,6 +419,7 @@ function TaskCreateModal({
|
||||
});
|
||||
};
|
||||
|
||||
// وظایف کاندید برای فرزند بودن (به جز والدی که هماکنون انتخاب شده)
|
||||
const candidateChildren = allTasks.filter(
|
||||
(t) => String(t.id || t.op_wp_id) !== selectedParentId
|
||||
);
|
||||
@@ -437,6 +444,7 @@ function TaskCreateModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ۱. تعیین وظیفه والد (PARENT) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
||||
وظیفه والد (سطح بالاتر - Parent)
|
||||
@@ -464,6 +472,7 @@ 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">
|
||||
@@ -567,14 +576,6 @@ 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
|
||||
@@ -594,9 +595,8 @@ function TaskCreateModal({
|
||||
setPlannedEnd(next.end);
|
||||
setDurationDays(next.days);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="مثال: ۵"
|
||||
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] leading-4 text-slate-400">
|
||||
روز، تاریخ پایان را میسازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
|
||||
@@ -621,11 +621,7 @@ function TaskCreateModal({
|
||||
setMaterials={setMaterials}
|
||||
/>
|
||||
|
||||
<HumanResourceInputs
|
||||
humanResources={humanResources}
|
||||
setHumanResources={setHumanResources}
|
||||
/>
|
||||
|
||||
{/* ۳. تعریف روابط وابستگی (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">
|
||||
@@ -639,6 +635,7 @@ function TaskCreateModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* لیست روابط پیشنویس */}
|
||||
{draftRelations.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{draftRelations.map((r, idx) => (
|
||||
@@ -675,6 +672,7 @@ 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>
|
||||
@@ -726,13 +724,8 @@ function TaskCreateModal({
|
||||
<input
|
||||
type="number"
|
||||
value={relLag}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -754,11 +747,11 @@ function TaskCreateModal({
|
||||
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
|
||||
direction: relDirection,
|
||||
relation_type: relType,
|
||||
lag_days: Number(relLag) || 0,
|
||||
lag_days: relLag,
|
||||
},
|
||||
]);
|
||||
setRelTargetId("");
|
||||
setRelLag("");
|
||||
setRelLag(0);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
@@ -771,11 +764,7 @@ function TaskCreateModal({
|
||||
<button type="button" onClick={onClose} className={btnSecondary}>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || isDateRangeInvalid}
|
||||
className={btnPrimary}
|
||||
>
|
||||
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
|
||||
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -796,6 +785,7 @@ function CreateRelationModal({
|
||||
projectId: string;
|
||||
tasks: Task[];
|
||||
initialFromTaskId: string | null;
|
||||
/** در حالت ویرایش: وابستگی موجود؛ دو سر وابستگی تغییرناپذیرند */
|
||||
editRelation?: TaskRelation | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -804,32 +794,24 @@ function CreateRelationModal({
|
||||
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
|
||||
const [toTaskId, setToTaskId] = useState("");
|
||||
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
|
||||
const [lagDays, setLagDays] = useState<number | "">("");
|
||||
const [lagDays, setLagDays] = useState(0);
|
||||
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 !== undefined && editRelation.lag_days !== null
|
||||
? editRelation.lag_days
|
||||
: ""
|
||||
);
|
||||
} else {
|
||||
setFromTaskId(initialFromTaskId || "");
|
||||
setToTaskId("");
|
||||
setRelationType("FS");
|
||||
setLagDays("");
|
||||
setLagDays(editRelation.lag_days || 0);
|
||||
}
|
||||
setErrorMsg("");
|
||||
}, [open, editRelation, initialFromTaskId]);
|
||||
}, [open, editRelation]);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
|
||||
@@ -842,7 +824,7 @@ function CreateRelationModal({
|
||||
invalidate();
|
||||
onClose();
|
||||
setToTaskId("");
|
||||
setLagDays("");
|
||||
setLagDays(0);
|
||||
setErrorMsg("");
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -951,13 +933,9 @@ function CreateRelationModal({
|
||||
<input
|
||||
type="number"
|
||||
value={lagDays}
|
||||
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`}
|
||||
onChange={(e) => setLagDays(Number(e.target.value))}
|
||||
placeholder="مثلاً ۲ (گیرش بتن)"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -989,6 +967,111 @@ function CreateRelationModal({
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -999,15 +1082,17 @@ export default function ProjectDetailPage() {
|
||||
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
|
||||
|
||||
const projectId = id ?? "";
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "gantt">("tasks");
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "gantt" | "members">("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);
|
||||
|
||||
@@ -1072,12 +1157,21 @@ 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: () => {
|
||||
@@ -1095,6 +1189,18 @@ 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();
|
||||
@@ -1141,6 +1247,58 @@ 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 && (
|
||||
@@ -1190,6 +1348,7 @@ 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"
|
||||
@@ -1209,6 +1368,15 @@ 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" && (
|
||||
@@ -1321,6 +1489,69 @@ 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
|
||||
@@ -1357,6 +1588,13 @@ export default function ProjectDetailPage() {
|
||||
setRelationToEdit(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddMemberModal
|
||||
open={showMemberModal}
|
||||
projectId={projectId}
|
||||
onClose={() => setShowMemberModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Folder, Plus, RefreshCw, ChevronLeft } from "lucide-react";
|
||||
import { Folder, Plus, RefreshCw, Users, MapPin, Calendar } from "lucide-react";
|
||||
import { createProject, getProjects } from "../lib/api";
|
||||
import type { CreateProjectPayload, Project } from "../lib/api";
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Modal,
|
||||
PageHeader,
|
||||
PanelsMenu,
|
||||
ProgressBar,
|
||||
btnPrimary,
|
||||
btnSecondary,
|
||||
inputCls,
|
||||
@@ -33,84 +34,73 @@ function ProjectCard({ project }: { project: Project }) {
|
||||
};
|
||||
|
||||
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
|
||||
const progressVal =
|
||||
project.progress !== undefined
|
||||
? Math.round(project.progress)
|
||||
: project.stats.total > 0
|
||||
? Math.round(
|
||||
Math.max(
|
||||
0,
|
||||
((project.stats.total - project.stats.overdue - project.stats.failed_qc) /
|
||||
project.stats.total) *
|
||||
100
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Link to={`/projects/${targetId}`} className="group block">
|
||||
<Card className="p-3 shadow-xs transition-all duration-150 hover:border-slate-300 hover:shadow-sm active:bg-slate-50/90">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* بلوک محتوایی ۲ سطره در سمت راست */}
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* سطر ۱: نام پروژه و بج پیل وضعیت */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="min-w-0 truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
||||
{project.name}
|
||||
<Link to={`/projects/${targetId}`} className="block">
|
||||
<Card className="p-4 active:bg-slate-100/60 transition-all hover:border-slate-300">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-bold text-slate-800">{project.name}</p>
|
||||
{currentStatus && <Chip tone={currentStatus.tone}>{currentStatus.label}</Chip>}
|
||||
</div>
|
||||
<p dir="ltr" className="mt-0.5 text-right font-mono text-[11px] text-slate-400">
|
||||
{project.identifier}
|
||||
</p>
|
||||
{project.stats.failed_qc > 0 ? (
|
||||
<span className="inline-flex shrink-0 items-center rounded-full bg-red-50 border border-red-200/60 px-2 py-0.5 text-[10px] font-bold text-red-600">
|
||||
{project.stats.failed_qc} مردود
|
||||
</span>
|
||||
) : currentStatus ? (
|
||||
<Chip tone={currentStatus.tone} className="text-[10px] py-0 px-2 shrink-0">
|
||||
{currentStatus.label}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center rounded-full bg-emerald-50 border border-emerald-200/60 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
در حال اجرا
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* سطر ۲: پیل تعداد فعالیت در راست — نوار پیشرفت باریک با درصد در چپ */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="inline-flex shrink-0 items-center rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
{project.stats.total} فعالیت
|
||||
</span>
|
||||
{project.stats.awaiting_qc > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
{project.stats.awaiting_qc} منتظر QC
|
||||
</span>
|
||||
)}
|
||||
{project.location && (
|
||||
<span className="truncate text-[10px] text-slate-400 hidden sm:inline">
|
||||
• {project.location}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin size={13} className="text-slate-400" />
|
||||
{project.location}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(project.planned_start_jalali || project.planned_end_jalali || project.actual_start_jalali) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||
{(project.planned_start_jalali || project.planned_end_jalali) && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar size={12} className="text-slate-400" />
|
||||
<span>برنامه:</span>
|
||||
<span className="font-medium text-slate-700">
|
||||
{project.planned_start_jalali || "—"} تا {project.planned_end_jalali || "—"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{project.actual_start_jalali && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700">
|
||||
<span>واقعی:</span>
|
||||
<span>{project.actual_start_jalali}</span>
|
||||
{project.actual_end_jalali && <span> تا {project.actual_end_jalali}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 w-28 sm:w-32 shrink-0 justify-end">
|
||||
<div dir="rtl" className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
progressVal >= 100 ? "bg-emerald-500" : "bg-sky-500"
|
||||
}`}
|
||||
style={{ width: `${progressVal}%` }}
|
||||
/>
|
||||
{project.progress !== undefined && (
|
||||
<div className="mt-3">
|
||||
<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">{Math.round(project.progress)}٪</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-left text-[10px] font-bold tabular-nums text-slate-500 w-7">
|
||||
{progressVal}٪
|
||||
<ProgressBar value={project.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<Chip tone="sky">کل: {project.stats.total}</Chip>
|
||||
<Chip tone="amber">انتظار QC: {project.stats.awaiting_qc}</Chip>
|
||||
{project.stats.failed_qc > 0 && <Chip tone="red">مردود: {project.stats.failed_qc}</Chip>}
|
||||
{project.stats.overdue > 0 && <Chip tone="red">عقبافتاده: {project.stats.overdue}</Chip>}
|
||||
{project.member_count !== undefined && project.member_count > 0 && (
|
||||
<span className="ms-auto inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<Users size={12} />
|
||||
{project.member_count} عضو
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* فلش تعاملی در منتهیالیه چپ با تراز عمودی کامل روی کل ارتفاع کارت */}
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
||||
<ChevronLeft size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -62,16 +62,6 @@ 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">
|
||||
@@ -81,6 +71,10 @@ function SectionCard({ title, children }: { title: ReactNode; children: ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// صفحه جزئیات وظیفه
|
||||
// ----------------------------------------
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -105,6 +99,7 @@ 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);
|
||||
|
||||
@@ -114,14 +109,7 @@ 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;
|
||||
@@ -160,6 +148,7 @@ export default function TaskDetailPage() {
|
||||
setConsumedHr([]);
|
||||
}
|
||||
|
||||
// یکپارچهسازی مصالح تخصیصیافته و مصارف قبلی
|
||||
const items: MaterialConsumption[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -169,7 +158,6 @@ 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 || "",
|
||||
@@ -178,13 +166,14 @@ export default function TaskDetailPage() {
|
||||
previous_consumed: past,
|
||||
total_consumed: past,
|
||||
remaining_amount: Math.max(0, alloc - past),
|
||||
is_over_allocated: isOver,
|
||||
over_allocated_amount: isOver ? past - alloc : 0,
|
||||
is_over_allocated: alloc > 0 ? past > alloc : past > 0,
|
||||
over_allocated_amount: Math.max(0, past - alloc),
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// مصالحی که در سوابق قبل مصرف شدهاند ولی در تخصیص اولیه نبودهاند
|
||||
pastConsumptionMap.forEach((val, key) => {
|
||||
if (!seen.has(key)) {
|
||||
items.push({
|
||||
@@ -195,8 +184,8 @@ export default function TaskDetailPage() {
|
||||
previous_consumed: val.total,
|
||||
total_consumed: val.total,
|
||||
remaining_amount: 0,
|
||||
is_over_allocated: false,
|
||||
over_allocated_amount: 0,
|
||||
is_over_allocated: val.total > 0,
|
||||
over_allocated_amount: val.total,
|
||||
note: "خارج از سقف مصوب",
|
||||
});
|
||||
}
|
||||
@@ -208,12 +197,14 @@ 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("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
|
||||
@@ -224,7 +215,8 @@ export default function TaskDetailPage() {
|
||||
});
|
||||
|
||||
const handleMaterialAmountChange = (idx: number, rawVal: string) => {
|
||||
const amt = parseCleanNumber(rawVal);
|
||||
const val = parseFloat(rawVal);
|
||||
const amt = Math.max(0, isNaN(val) ? 0 : val);
|
||||
setConsumedMaterials((prev) => {
|
||||
const next = [...prev];
|
||||
const item = { ...next[idx] };
|
||||
@@ -232,8 +224,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;
|
||||
const overAmt = isOver ? tot - alloc : 0;
|
||||
const isOver = alloc > 0 ? tot > alloc : tot > 0;
|
||||
const overAmt = Math.max(0, tot - alloc);
|
||||
|
||||
item.amount = amt;
|
||||
item.total_consumed = tot;
|
||||
@@ -258,7 +250,7 @@ export default function TaskDetailPage() {
|
||||
...prev,
|
||||
{
|
||||
name: "",
|
||||
unit: "عدد",
|
||||
unit: "",
|
||||
amount: 0,
|
||||
allocated_amount: 0,
|
||||
previous_consumed: 0,
|
||||
@@ -266,7 +258,7 @@ export default function TaskDetailPage() {
|
||||
remaining_amount: 0,
|
||||
is_over_allocated: false,
|
||||
over_allocated_amount: 0,
|
||||
note: "مصالح خارج از برنامه",
|
||||
note: "مصالح مازاد / خارج از برنامه",
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -281,7 +273,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;
|
||||
@@ -294,6 +286,7 @@ export default function TaskDetailPage() {
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخها ناموفق بود"),
|
||||
});
|
||||
|
||||
// ۱. وضعیت و جهش ویرایش منابع تخصیصیافته به وظیفه
|
||||
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
|
||||
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
|
||||
|
||||
@@ -317,6 +310,7 @@ 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");
|
||||
@@ -342,7 +336,7 @@ export default function TaskDetailPage() {
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
|
||||
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی نامعتبر)");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -405,6 +399,8 @@ 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;
|
||||
@@ -423,6 +419,7 @@ 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;
|
||||
@@ -454,6 +451,7 @@ 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>
|
||||
@@ -495,7 +493,7 @@ export default function TaskDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* کارت پیشرفت و مدیریت یکپارچه مصالح */}
|
||||
{/* کارت یکپارچه پیشرفت، تخصیص و گزارش مصرف مصالح و نیروی انسانی */}
|
||||
<SectionCard
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -540,6 +538,7 @@ 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
|
||||
@@ -573,7 +572,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">
|
||||
@@ -583,9 +582,9 @@ export default function TaskDetailPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomMaterial}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<Plus size={13} />
|
||||
<Plus size={12} />
|
||||
<span>افزودن مصالح خارج برنامه</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -595,15 +594,15 @@ export default function TaskDetailPage() {
|
||||
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. میتوانید با دکمه بالا مصالح جدید اضافه کنید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
{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;
|
||||
const overAmt = isOver ? totC - alloc : 0;
|
||||
const isOver = alloc > 0 ? totC > alloc : totC > 0;
|
||||
const overAmt = Math.max(0, totC - alloc);
|
||||
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
|
||||
const specs =
|
||||
task?.materials?.find(
|
||||
@@ -613,50 +612,40 @@ export default function TaskDetailPage() {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`rounded-xl border p-3 space-y-3 bg-white shadow-2xs transition-all ${
|
||||
className={`rounded-xl border p-2.5 space-y-2.5 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="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>
|
||||
<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-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"
|
||||
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"
|
||||
/>
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<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>}
|
||||
<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>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specs.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<div className="mt-1 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-[10px] border border-slate-200"
|
||||
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[9.5px] border border-slate-200"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
@@ -665,21 +654,17 @@ export default function TaskDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 pt-0.5">
|
||||
{/* نشانگر وضعیت سقف یا دکمه حذف */}
|
||||
<div className="shrink-0">
|
||||
{alloc === 0 ? (
|
||||
<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} />
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${
|
||||
@@ -688,94 +673,74 @@ 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-2 text-center text-xs">
|
||||
{/* ۲. جدول ۳بخشی منظم حسابداری مصالح (مقدار کل مصوب، مصرف تاکنون، مانده قابل مصرف) */}
|
||||
<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="px-1">
|
||||
<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 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>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<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 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>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<span
|
||||
className={`block text-[10px] font-bold ${
|
||||
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
|
||||
}`}
|
||||
>
|
||||
{isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"}
|
||||
<span className={`block text-[9.5px] ${isOver ? "text-rose-600 font-bold" : "text-emerald-700 font-bold"}`}>
|
||||
{isOver ? "مازاد سقف" : "مانده قابل مصرف"}
|
||||
</span>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* ۳. ورودی شاخص و مستقل «مصرف این گزارش *» */}
|
||||
<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">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span>مصرف این گزارش *:</span>
|
||||
</label>
|
||||
<div className="relative flex items-center w-36">
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
type="number"
|
||||
step="any"
|
||||
min={0}
|
||||
value={item.amount === 0 ? "" : item.amount}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
|
||||
placeholder="۰"
|
||||
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"
|
||||
className="h-8 w-24 bg-transparent px-2 text-center text-sm font-black text-slate-900 focus:outline-none"
|
||||
/>
|
||||
{item.unit && (
|
||||
<span className="absolute left-2 text-[11px] font-bold text-slate-500 pointer-events-none">
|
||||
<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">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentC > 0 && (
|
||||
<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}
|
||||
<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}`}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ۴. نوار پیشرفت مصرف به سقف */}
|
||||
{alloc > 0 && (
|
||||
<div className="space-y-1 pt-0.5">
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-500">
|
||||
<div className="flex items-center justify-between text-[9.5px] 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}٪ از سقف`}
|
||||
@@ -792,13 +757,14 @@ export default function TaskDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ۵. هشدار اضافه مصرف جهت ثبت در سوابق و استعلام */}
|
||||
{isOver && (
|
||||
<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" />
|
||||
<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" />
|
||||
<span>
|
||||
میزان مصرف (<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} مازاد)
|
||||
میزان مصرف ({totC} {item.unit}) از سقف تخصیص ({alloc}) فراتر رفته است. مازاد (+{overAmt} {item.unit}) در استعلامهای آتی قابل رهگیری است.
|
||||
</span>
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -807,7 +773,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">
|
||||
@@ -896,6 +862,7 @@ 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">
|
||||
توضیحات و گزارش روزانه کارگاه (اختیاری)
|
||||
@@ -936,7 +903,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">
|
||||
@@ -954,10 +921,11 @@ 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>
|
||||
@@ -976,6 +944,7 @@ 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>
|
||||
@@ -1003,6 +972,7 @@ 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}
|
||||
@@ -1014,7 +984,7 @@ export default function TaskDetailPage() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* کارت تاریخهای وظیفه */}
|
||||
{/* کارت تاریخها — برنامهریزیشده و واقعی */}
|
||||
<SectionCard title="تاریخهای وظیفه">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -1031,17 +1001,9 @@ 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 || isDateRangeInvalid}
|
||||
disabled={datesMutation.isPending}
|
||||
onClick={() => datesMutation.mutate()}
|
||||
className={`${btnSecondary} mt-2.5 w-full`}
|
||||
>
|
||||
@@ -1101,6 +1063,8 @@ export default function TaskDetailPage() {
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
|
||||
{/* کارت ارتباطات */}
|
||||
<SectionCard
|
||||
title={
|
||||
@@ -1251,6 +1215,7 @@ export default function TaskDetailPage() {
|
||||
onUpdated={() => invalidateTask()}
|
||||
/>
|
||||
|
||||
{/* مودال ویرایش منابع تخصیصیافته به وظیفه */}
|
||||
{showEditResourcesModal && (
|
||||
<Modal
|
||||
open={showEditResourcesModal}
|
||||
@@ -1283,6 +1248,7 @@ export default function TaskDetailPage() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* مودال افزودن رابطه وابستگی جدید */}
|
||||
{showAddRelationModal && (
|
||||
<Modal
|
||||
open={showAddRelationModal}
|
||||
|
||||
+184
-561
@@ -1,244 +1,103 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ClipboardCheck,
|
||||
RefreshCw,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles, getProjects } from "../lib/api";
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Folder, RefreshCw } from "lucide-react";
|
||||
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles } from "../lib/api";
|
||||
import type { DashboardTask, Project } from "../lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardSkeleton,
|
||||
Chip,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
PageHeader,
|
||||
PanelsMenu,
|
||||
type SegmentedOption,
|
||||
} from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { getDashboardHeaderTitle, getRoleTitle } from "../lib/roles";
|
||||
import { Card, CardSkeleton, Chip, EmptyState, ErrorState, PageHeader, PanelsMenu, ProgressBar } from "../components/ui";
|
||||
import { formatDate, isOverdue } from "../lib/format";
|
||||
import { roleLabels, scopeLabel } from "../lib/roles";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
|
||||
type FieldTab = "all" | "pending_approval" | "in_progress";
|
||||
type SectionKey = "overdue" | "due_today" | "awaiting_qc" | "failed_qc" | "blocked" | "upcoming";
|
||||
|
||||
/** کارت اکشن فوری برای موارد QC مردود (طرح اضطراری با دکمه تمامعرض) */
|
||||
function QcFailedEmergencyCard({
|
||||
task,
|
||||
projectName,
|
||||
}: {
|
||||
task: DashboardTask;
|
||||
projectName: string;
|
||||
}) {
|
||||
interface SectionMeta {
|
||||
key: SectionKey;
|
||||
title: string;
|
||||
tone: "red" | "amber" | "sky" | "slate";
|
||||
}
|
||||
|
||||
/**
|
||||
* بخشهای داشبورد به تفکیک نقش:
|
||||
* مدیر پروژه نگاه مدیریتی دارد (تأخیرها و QC همه پروژهها) و سرپرست پروژه نگاه
|
||||
* عملیاتی (کار امروز و کاری که باید همین حالا اجرا/بازرسی شود).
|
||||
*/
|
||||
const managerSections: SectionMeta[] = [
|
||||
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
||||
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
||||
{ key: "awaiting_qc", title: "منتظر QC", tone: "amber" },
|
||||
{ key: "failed_qc", title: "QC مردود", tone: "red" },
|
||||
{ key: "blocked", title: "مسدود شده", tone: "slate" },
|
||||
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
||||
];
|
||||
|
||||
const supervisorSections: SectionMeta[] = [
|
||||
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
||||
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
||||
{ key: "awaiting_qc", title: "منتظر اجرای بازرسی", tone: "amber" },
|
||||
{ key: "failed_qc", title: "بازرسی مردود", tone: "red" },
|
||||
{ key: "blocked", title: "مسدود شده (پیشنیاز ناتمام)", tone: "slate" },
|
||||
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
||||
];
|
||||
|
||||
function TaskRow({ task, showBlockedBy = false }: { task: DashboardTask; showBlockedBy?: boolean }) {
|
||||
const overdue = isOverdue(task.due_date);
|
||||
// شناسهٔ پایدار: UUID تسک بومی، وگرنه op_wp_id میراث
|
||||
const taskKey = dashboardTaskKey(task);
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-red-200/90 bg-gradient-to-l from-red-50/70 via-white to-white p-3.5 shadow-xs border-r-4 border-r-red-600 transition-all hover:border-red-300">
|
||||
<Link to={`/tasks/${taskKey}`} className="block py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-xs sm:text-[13px] font-bold text-slate-800 leading-snug min-w-0 flex-1">
|
||||
{task.subject}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold text-red-700 border border-red-200/60">
|
||||
عدم انطباق بحرانی
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 grid grid-cols-3 gap-2 rounded-lg bg-slate-50/90 p-2 text-xs border border-slate-100">
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10px] text-slate-400">پروژه فعال</span>
|
||||
<span className="truncate font-semibold text-slate-700 text-[11px] block mt-0.5">
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 text-center">
|
||||
<span className="block text-[10px] text-slate-400">تاریخ بازرسی</span>
|
||||
<span className="font-semibold text-red-600 text-[11px] block mt-0.5 tabular-nums">
|
||||
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{task.subject}</p>
|
||||
<span className={`shrink-0 text-[11px] ${overdue ? "font-semibold text-red-600" : "text-slate-400"}`}>
|
||||
{formatDate(task.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<span className="block text-[10px] text-slate-400">پیشرفت فعلی</span>
|
||||
<span className="font-bold text-slate-700 text-[11px] block mt-0.5 tabular-nums">
|
||||
{task.percentage_done}٪
|
||||
</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ProgressBar value={task.percentage_done} className="flex-1" />
|
||||
<span className="w-9 shrink-0 text-left text-[10px] text-slate-400">{task.percentage_done}٪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
to={`/tasks/${taskKey}`}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-red-600 py-2.5 text-xs font-bold text-white shadow-xs transition-all hover:bg-red-700 active:scale-[0.99]"
|
||||
>
|
||||
<span>مشاهده عدم انطباق و ثبت اقدام اصلاحی</span>
|
||||
<ChevronLeft size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ردیف فشرده و مدرن چکلیست بازرسی روزانه (ارتفاع ۵۶ تا ۶۴ پیکسل) */
|
||||
function TodayChecklistRow({
|
||||
task,
|
||||
projectName,
|
||||
phase,
|
||||
}: {
|
||||
task: DashboardTask;
|
||||
projectName: string;
|
||||
phase?: string;
|
||||
}) {
|
||||
const taskKey = dashboardTaskKey(task);
|
||||
|
||||
const statusMeta =
|
||||
task.qc_status === "pre_qc_pending" || task.qc_status === "exec_qc_pending"
|
||||
? { label: "در انتظار بازرسی", bg: "bg-amber-50 text-amber-700 border-amber-200/60" }
|
||||
: task.qc_status === "ready"
|
||||
? { label: "آماده اجرا / تایید", bg: "bg-sky-50 text-sky-700 border-sky-200/60" }
|
||||
: task.qc_status === "in_progress"
|
||||
? { label: "در حال بررسی", bg: "bg-slate-100 text-slate-700 border-slate-200" }
|
||||
: task.qc_status === "final_passed"
|
||||
? { label: "تأیید نهایی", bg: "bg-emerald-50 text-emerald-700 border-emerald-200/60" }
|
||||
: { label: "آماده اقدام", bg: "bg-sky-50 text-sky-700 border-sky-200/60" };
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60px] items-center justify-between gap-3 px-3.5 py-2.5 bg-white transition-colors hover:bg-slate-50/80">
|
||||
{/* سمت راست: آیکون وضعیت + عنوان چکلیست (با شکست تا ۲ خط بدون truncate سخت) و مرحله */}
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sky-50 text-sky-600 border border-sky-100 self-center">
|
||||
<ClipboardCheck size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-xs font-bold text-slate-800 leading-snug break-words">
|
||||
{task.subject}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 truncate">
|
||||
<span className="font-medium text-slate-500 truncate">{projectName}</span>
|
||||
{phase && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="truncate">{phase}</span>
|
||||
</>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<span className="truncate">{task.project_name}</span>
|
||||
{showBlockedBy && task.blocked_by && (
|
||||
<span className="shrink-0 truncate text-slate-500">مسدود توسط: {task.blocked_by}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* سمت چپ: پیل فشرده وضعیت + دکمه آیکونی کوچک برای بازکردن بازرسی (تراز عمودی مرکز) */}
|
||||
<div className="flex items-center gap-2 shrink-0 self-center">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold border ${statusMeta.bg}`}>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
<Link
|
||||
to={`/tasks/${taskKey}`}
|
||||
aria-label="بازکردن بازرسی"
|
||||
title="بازکردن فرم بازرسی"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 active:scale-95 transition-all"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** کارت پیشنمایش وظایف و نقاط عطف هفته آینده */
|
||||
function UpcomingTaskCard({
|
||||
task,
|
||||
projectName,
|
||||
}: {
|
||||
task: DashboardTask;
|
||||
projectName: string;
|
||||
}) {
|
||||
const taskKey = dashboardTaskKey(task);
|
||||
return (
|
||||
<Link
|
||||
to={`/tasks/${taskKey}`}
|
||||
className="group block rounded-xl border border-slate-200/80 bg-white p-3 shadow-xs transition-all duration-150 hover:border-sky-300 hover:shadow-sm active:bg-slate-50/80"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<p className="truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
||||
{task.subject}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
|
||||
<span className="inline-flex items-center rounded-md bg-slate-100 px-2 py-0.5 font-medium text-slate-600 truncate max-w-[150px]">
|
||||
{projectName}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-sky-50 border border-sky-200/60 px-2 py-0.5 font-semibold text-sky-700 tabular-nums">
|
||||
<Calendar size={11} className="text-sky-600" />
|
||||
<span>موعد: {formatDate(task.due_date)}</span>
|
||||
</span>
|
||||
)}
|
||||
{task.percentage_done > 0 && (
|
||||
<span className="inline-flex items-center rounded-md bg-slate-50 px-1.5 py-0.5 text-slate-400 tabular-nums">
|
||||
{task.percentage_done}٪
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
||||
<ChevronLeft size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** کارت شاخص آماری با عناوین کوتاه و بدون بریدگی متن */
|
||||
function SummaryBox({
|
||||
tone,
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
}: {
|
||||
tone: "red" | "amber" | "green";
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: LucideIcon;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
function SummaryBox({ tone, label, value }: { tone: "red" | "amber" | "green"; label: string; value: number }) {
|
||||
const cls = {
|
||||
red: "border-red-200/80 bg-red-50/60 text-red-700 hover:border-red-300",
|
||||
amber: "border-amber-200/80 bg-amber-50/60 text-amber-700 hover:border-amber-300",
|
||||
green: "border-emerald-200/80 bg-emerald-50/60 text-emerald-700 hover:border-emerald-300",
|
||||
red: "border-red-100 bg-red-50 text-red-600",
|
||||
amber: "border-amber-100 bg-amber-50 text-amber-600",
|
||||
green: "border-emerald-100 bg-emerald-50 text-emerald-600",
|
||||
}[tone];
|
||||
|
||||
const iconCls = {
|
||||
red: "text-red-500",
|
||||
amber: "text-amber-500",
|
||||
green: "text-emerald-500",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-xl border p-2 text-center transition-all duration-150 active:scale-95 ${cls} hover:shadow-xs`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{Icon && <Icon size={16} className={`${iconCls} shrink-0 opacity-90`} />}
|
||||
<span className="text-lg font-black tabular-nums">{value}</span>
|
||||
<div className={`rounded-2xl border p-3 text-center ${cls}`}>
|
||||
<p className="text-xl font-extrabold">{value}</p>
|
||||
<p className="mt-0.5 text-[11px] font-medium opacity-80">{label}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs font-bold leading-tight truncate">{label}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** کاشی مدیریتی یک پروژه — فقط در داشبورد مدیر پروژه */
|
||||
function ProjectTile({ project }: { project: Project }) {
|
||||
return (
|
||||
<Link to={`/projects/${project.id || project.op_project_id}`} className="block">
|
||||
<div className="rounded-xl border border-slate-200 p-3 active:bg-slate-50">
|
||||
<p className="truncate text-xs font-bold text-slate-800">{project.name}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<Chip tone="sky">{project.stats.total} فعالیت</Chip>
|
||||
{project.stats.overdue > 0 && <Chip tone="red">{project.stats.overdue} عقبافتاده</Chip>}
|
||||
{project.stats.awaiting_qc > 0 && <Chip tone="amber">{project.stats.awaiting_qc} منتظر QC</Chip>}
|
||||
{project.stats.failed_qc > 0 && <Chip tone="red">{project.stats.failed_qc} مردود</Chip>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** انتخابگر نقش در حالت توسعه */
|
||||
/** انتخابگر نقش — فقط در فاز توسعه (سرور آن را با PM_DEV_ROLE_SWITCH کنترل میکند) */
|
||||
function DevRoleSwitcher() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
@@ -281,7 +140,7 @@ function DevRoleSwitcher() {
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.username} value={r.username}>
|
||||
{getRoleTitle(r.role)} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
||||
{roleLabels[r.role]} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -291,170 +150,28 @@ function DevRoleSwitcher() {
|
||||
export default function TodayPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
// تب فعال عملیاتی چکلیستهای روزانه: همه اقدامات | در انتظار تایید / بازرسی | در حال بررسی
|
||||
const [activeTab, setActiveTab] = useState<FieldTab>("all");
|
||||
const [showUpcoming, setShowUpcoming] = useState<boolean | null>(null);
|
||||
const emergencyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCriticalClick = () => {
|
||||
if (emergencyRef.current) {
|
||||
emergencyRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
// واکشی دادههای داشبورد امروز و لیست پروژهها از اندپوینتهای واقعی
|
||||
const [showUpcoming, setShowUpcoming] = useState(false);
|
||||
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
|
||||
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
|
||||
const data = todayQuery.data;
|
||||
|
||||
const refresh = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
};
|
||||
|
||||
// لیست کلی پروژهها مستقیماً از دادههای API
|
||||
const allProjects = useMemo(() => {
|
||||
return projectsQuery.data ?? data?.projects ?? [];
|
||||
}, [projectsQuery.data, data?.projects]);
|
||||
const isManager = user?.role === "project_manager";
|
||||
const role = data?.role ?? user?.role ?? "project_supervisor";
|
||||
const sections = isManager ? managerSections : supervisorSections;
|
||||
const projects = data?.projects ?? [];
|
||||
|
||||
// مدیریت شناسه پروژه فعال با ذخیرهسازی محلی
|
||||
const [activeProjectId, setActiveProjectId] = useState<string>(() => {
|
||||
return localStorage.getItem("pm_active_project_id") || "";
|
||||
});
|
||||
|
||||
const activeProject = useMemo(() => {
|
||||
if (allProjects.length === 0) return null;
|
||||
const found = allProjects.find(
|
||||
(p) => String(p.id || (p as Project).op_project_id) === String(activeProjectId)
|
||||
);
|
||||
return found || allProjects[0];
|
||||
}, [allProjects, activeProjectId]);
|
||||
|
||||
// همگامسازی خودکار شناسه در صورت نامعتبر بودن مقدار محلی
|
||||
useEffect(() => {
|
||||
if (activeProject) {
|
||||
const currentId = String(activeProject.id || (activeProject as Project).op_project_id);
|
||||
if (currentId !== activeProjectId) {
|
||||
setActiveProjectId(currentId);
|
||||
localStorage.setItem("pm_active_project_id", currentId);
|
||||
}
|
||||
}
|
||||
}, [activeProject, activeProjectId]);
|
||||
|
||||
const currentPhase =
|
||||
activeProject?.description?.trim() ||
|
||||
(activeProject?.status === "ACTIVE" ? "در حال ساخت / اجرا" : activeProject?.status) ||
|
||||
"در حال اجرا";
|
||||
|
||||
const handleSelectProject = (id: string) => {
|
||||
setActiveProjectId(id);
|
||||
localStorage.setItem("pm_active_project_id", id);
|
||||
try {
|
||||
const raw = localStorage.getItem("procurement-auth-v1");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.state?.user) {
|
||||
parsed.state.user.project_id = id;
|
||||
localStorage.setItem("procurement-auth-v1", JSON.stringify(parsed));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// وظایف و چکلیستهای عملیاتی کارگاه از اندپوینت واقعی dashboard/today
|
||||
const failedQcTasks = useMemo(() => {
|
||||
const list = data?.sections?.failed_qc ?? [];
|
||||
if (!activeProject?.id) return list;
|
||||
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||
}, [data?.sections?.failed_qc, activeProject?.id]);
|
||||
|
||||
const overdueTasks = useMemo(() => {
|
||||
const list = data?.sections?.overdue ?? [];
|
||||
if (!activeProject?.id) return list;
|
||||
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||
}, [data?.sections?.overdue, activeProject?.id]);
|
||||
|
||||
const todayChecklists = useMemo(() => {
|
||||
if (!data?.sections) return [];
|
||||
const dueToday = data.sections.due_today ?? [];
|
||||
const awaitingQc = data.sections.awaiting_qc ?? [];
|
||||
const combined = [...dueToday, ...awaitingQc];
|
||||
if (!activeProject?.id) return combined;
|
||||
return combined.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||
}, [data?.sections, activeProject?.id]);
|
||||
|
||||
const upcomingTasks = useMemo(() => {
|
||||
const list = data?.sections?.upcoming ?? [];
|
||||
if (!activeProject?.id) return list;
|
||||
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||
}, [data?.sections?.upcoming, activeProject?.id]);
|
||||
|
||||
// فیلتر چکلیستهای عملیاتی کارگاه
|
||||
const pendingApprovalList = useMemo(() => {
|
||||
return todayChecklists.filter((t) => t.qc_status !== "in_progress");
|
||||
}, [todayChecklists]);
|
||||
|
||||
const inProgressList = useMemo(() => {
|
||||
return todayChecklists.filter((t) => t.qc_status === "in_progress");
|
||||
}, [todayChecklists]);
|
||||
|
||||
const displayedChecklists = useMemo(() => {
|
||||
if (activeTab === "pending_approval") return pendingApprovalList;
|
||||
if (activeTab === "in_progress") return inProgressList;
|
||||
return todayChecklists;
|
||||
}, [activeTab, todayChecklists, pendingApprovalList, inProgressList]);
|
||||
|
||||
// محاسبه شاخصهای سلامت کارگاه بر اساس دادههای واقعی سرور
|
||||
const criticalCount = activeProject?.stats
|
||||
? (activeProject.stats.failed_qc ?? 0) + (activeProject.stats.overdue ?? 0)
|
||||
: failedQcTasks.length + overdueTasks.length;
|
||||
|
||||
const pendingApprovalCount = pendingApprovalList.length;
|
||||
|
||||
const verifiedChecklistsCount = activeProject?.stats
|
||||
? (activeProject.stats.completed ?? 0)
|
||||
: (data?.summary?.ok ?? 0);
|
||||
|
||||
// تبهای فیلتر چکلیستهای روزانه کارگاه
|
||||
const filterTabs = useMemo<SegmentedOption<FieldTab>[]>(() => [
|
||||
{ id: "all", label: "همه اقدامات", count: todayChecklists.length },
|
||||
{
|
||||
id: "pending_approval",
|
||||
label: "در انتظار تایید / بازرسی",
|
||||
count: pendingApprovalList.length,
|
||||
tone: pendingApprovalList.length > 0 ? "amber" : undefined,
|
||||
},
|
||||
{
|
||||
id: "in_progress",
|
||||
label: "در حال بررسی",
|
||||
count: inProgressList.length,
|
||||
},
|
||||
], [todayChecklists.length, pendingApprovalList.length, inProgressList.length]);
|
||||
|
||||
const activeRole = data?.role || user?.role || "site_supervisor";
|
||||
const roleTitle = getRoleTitle(activeRole, user?.role_label || data?.role_label);
|
||||
const headerTitle = getDashboardHeaderTitle(activeRole);
|
||||
const userDisplayName = user?.display_name || user?.username || "کاربر سیستم";
|
||||
const totalItems = data
|
||||
? Object.values(data.sections).reduce((sum, list) => sum + list.length, 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={headerTitle}
|
||||
sub={
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500 font-normal">
|
||||
<span className="font-semibold text-slate-700">{userDisplayName}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="font-medium text-sky-600">{roleTitle}</span>
|
||||
</span>
|
||||
}
|
||||
menu={
|
||||
<PanelsMenu
|
||||
projects={allProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
onSelectProject={handleSelectProject}
|
||||
/>
|
||||
}
|
||||
title="مدیریت پروژه"
|
||||
menu={<PanelsMenu />}
|
||||
right={
|
||||
<span className="flex items-center gap-1">
|
||||
<DevRoleSwitcher />
|
||||
@@ -462,237 +179,143 @@ export default function TodayPage() {
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
aria-label="بهروزرسانی"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-500 hover:text-slate-700 active:bg-slate-100"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={todayQuery.isFetching || projectsQuery.isFetching ? "animate-spin" : ""}
|
||||
/>
|
||||
<RefreshCw size={20} className={todayQuery.isFetching ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3.5 px-4 pb-24 pt-2.5">
|
||||
{todayQuery.isPending || projectsQuery.isPending ? (
|
||||
<div className="space-y-3.5 py-4">
|
||||
<div className="flex items-center justify-center gap-2 py-3 text-xs font-medium text-slate-500">
|
||||
<RefreshCw size={16} className="animate-spin text-sky-600" />
|
||||
<span>در حال بارگذاری اطلاعات پروژه و وظایف...</span>
|
||||
<div className="px-4 pb-28 pt-4">
|
||||
{/* هویت نقش و محدوده دسترسی */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<Chip tone={isManager ? "sky" : "green"}>{roleLabels[role]}</Chip>
|
||||
<Chip tone="slate">{scopeLabel(user)}</Chip>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{isManager
|
||||
? "نمای مدیریتی همه پروژهها"
|
||||
: "نمای عملیاتی پروژههای اختصاصیافته"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-9 animate-pulse rounded-full bg-slate-200/80" />
|
||||
|
||||
{todayQuery.isPending ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
||||
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
||||
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||
</div>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
) : todayQuery.isError || projectsQuery.isError ? (
|
||||
) : todayQuery.isError ? (
|
||||
<ErrorState onRetry={refresh} />
|
||||
) : allProjects.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="پروژهای یافت نشد"
|
||||
description="شما به هیچ پروژهای دسترسی ندارید یا هنوز پروژهای در سامانه تعریف نشده است."
|
||||
/>
|
||||
</Card>
|
||||
) : !activeProject ? (
|
||||
<Card className="p-8 text-center">
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="پروژهای انتخاب نشده است"
|
||||
description="لطفاً از منوی سامانه یک پروژه را انتخاب نمایید."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
) : data ? (
|
||||
<>
|
||||
{/* ---------------------------------------------------- */}
|
||||
{/* ۱. نمایش وضعیت پروژه و کارگاه فعال */}
|
||||
{/* ---------------------------------------------------- */}
|
||||
<div className="space-y-1.5 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100 py-1.5 px-3.5 text-xs font-bold text-slate-800 border border-slate-200/80 shadow-2xs">
|
||||
<Building2 size={13} className="text-sky-600 shrink-0" />
|
||||
<span>پروژه فعال: {activeProject.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 leading-normal px-1">
|
||||
<span className="text-slate-400 font-normal">مرحله: </span>
|
||||
<span className="font-bold text-slate-700">{currentPhase}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------- */}
|
||||
{/* ۲. کارتهای شاخص سلامت کارگاه با عناوین کوتاه و بدون بریدگی */}
|
||||
{/* ---------------------------------------------------- */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<SummaryBox
|
||||
tone="red"
|
||||
label="بحرانی"
|
||||
value={criticalCount}
|
||||
icon={AlertCircle}
|
||||
onClick={handleCriticalClick}
|
||||
label={isManager ? "مشکلات" : "بحرانی"}
|
||||
value={data.summary.problems}
|
||||
/>
|
||||
<SummaryBox
|
||||
tone="amber"
|
||||
label="هشدارها"
|
||||
value={pendingApprovalCount}
|
||||
icon={AlertTriangle}
|
||||
onClick={() => setActiveTab("pending_approval")}
|
||||
/>
|
||||
<SummaryBox
|
||||
tone="green"
|
||||
label="تأیید شده"
|
||||
value={verifiedChecklistsCount}
|
||||
icon={CheckCircle2}
|
||||
onClick={() => setActiveTab("all")}
|
||||
label={isManager ? "هشدارها" : "نیازمند پیگیری"}
|
||||
value={data.summary.warnings}
|
||||
/>
|
||||
<SummaryBox tone="green" label="سالم" value={data.summary.ok} />
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------- */}
|
||||
{/* ۳. بخش اقدام فوری: QC مردود و عدم انطباق (مستقل و رندر مشروط) */}
|
||||
{/* ---------------------------------------------------- */}
|
||||
{failedQcTasks.length > 0 && (
|
||||
<div ref={emergencyRef} id="emergency-section" className="space-y-2 scroll-mt-20">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-xs font-bold text-red-700 flex items-center gap-1.5">
|
||||
<AlertCircle size={15} className="text-red-600 shrink-0" />
|
||||
<span>نیازمند اقدام فوری (عدم انطباق و QC مردود)</span>
|
||||
</h2>
|
||||
<Chip tone="red">{failedQcTasks.length} مورد</Chip>
|
||||
{/* نمای مدیریتی پروژهها — فقط مدیر پروژه */}
|
||||
{isManager && projects.length > 0 && (
|
||||
<Card className="mt-4 p-4">
|
||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-slate-700">وضعیت پروژهها</h2>
|
||||
<Link to="/projects" className="text-[11px] font-semibold text-sky-600">
|
||||
همه پروژهها
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{failedQcTasks.map((t) => (
|
||||
<QcFailedEmergencyCard
|
||||
key={dashboardTaskKey(t)}
|
||||
task={t}
|
||||
projectName={activeProject.name}
|
||||
/>
|
||||
{projects.map((p) => (
|
||||
<ProjectTile key={p.op_project_id} project={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------- */}
|
||||
{/* ۴. چیپهای فیلتر عملیاتی چکلیستهای روزانه */}
|
||||
{/* ---------------------------------------------------- */}
|
||||
<div className="w-full overflow-x-auto no-scrollbar flex items-center gap-2 px-1 py-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{filterTabs.map((opt) => {
|
||||
const active = opt.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(opt.id)}
|
||||
className={`shrink-0 flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all duration-150 select-none ${
|
||||
active
|
||||
? "bg-slate-900 text-white shadow-xs"
|
||||
: "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{opt.count !== undefined && opt.count > 0 && (
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.2 text-[10px] font-bold tabular-nums ${
|
||||
opt.tone === "red"
|
||||
? active ? "bg-red-500 text-white" : "bg-red-100 text-red-700"
|
||||
: opt.tone === "amber"
|
||||
? active ? "bg-amber-500 text-white" : "bg-amber-100 text-amber-700"
|
||||
: active ? "bg-slate-700 text-slate-200" : "bg-slate-200 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{opt.count}
|
||||
</span>
|
||||
{/* ناظر بدون پروژه اختصاصیافته */}
|
||||
{!isManager && (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) === 0 && totalItems === 0 && (
|
||||
<Card className="mt-4">
|
||||
<EmptyState
|
||||
icon={Folder}
|
||||
title="پروژهای به شما اختصاص داده نشده است"
|
||||
description="برای مشاهده و ثبت اجرا، مدیر پروژه باید پروژهها را از بخش «کاربران و دسترسیها» به شما اختصاص دهد"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{totalItems === 0 && (isManager || (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) > 0) ? (
|
||||
<Card className="mt-4">
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title="همه چیز مرتب است"
|
||||
description="در حال حاضر موردی برای پیگیری وجود ندارد"
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{sections.map(({ key, title, tone }) => {
|
||||
const items = data.sections[key];
|
||||
if (!items || items.length === 0) return null;
|
||||
const isUpcoming = key === "upcoming";
|
||||
|
||||
if (isUpcoming && !showUpcoming) {
|
||||
return (
|
||||
<button key={key} type="button" onClick={() => setShowUpcoming(true)} className="block w-full">
|
||||
<Card className="flex items-center justify-between p-4">
|
||||
<span className="text-sm font-bold text-slate-700">{title}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Chip tone="sky">{items.length} مورد</Chip>
|
||||
<ChevronDown size={16} className="text-slate-400" />
|
||||
</span>
|
||||
</Card>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={key} className="p-0">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h2 className="text-sm font-bold text-slate-700">{title}</h2>
|
||||
<span className="flex items-center gap-2">
|
||||
<Chip tone={tone}>{items.length} مورد</Chip>
|
||||
{isUpcoming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpcoming(false)}
|
||||
aria-label="بستن بخش"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 px-4">
|
||||
{items.map((t) => (
|
||||
<TaskRow key={dashboardTaskKey(t)} task={t} showBlockedBy={key === "blocked"} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------- */}
|
||||
{/* ۵. بخش چکلیستها و تأیدیههای امروز کارگاه (طرح سطری فشرده) */}
|
||||
{/* ---------------------------------------------------- */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<ClipboardCheck size={15} className="text-sky-600 shrink-0" />
|
||||
<span>چکلیستها و تأیدیههای امروز</span>
|
||||
</h2>
|
||||
<Chip tone="sky">{displayedChecklists.length} مورد</Chip>
|
||||
</div>
|
||||
|
||||
{displayedChecklists.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-xs divide-y divide-slate-100">
|
||||
{displayedChecklists.map((t) => (
|
||||
<TodayChecklistRow
|
||||
key={dashboardTaskKey(t)}
|
||||
task={t}
|
||||
projectName={activeProject.name}
|
||||
phase={currentPhase}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-4 text-center">
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title="چکلیستی برای امروز باقی نمانده است"
|
||||
description="هیچ موردی در این وضعیت برای پروژه انتخابشده یافت نشد."
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------- */}
|
||||
{/* ۶. بخش برنامه آتی و نقاط عطف کارگاه (هفته آینده) */}
|
||||
{/* ---------------------------------------------------- */}
|
||||
<Card className="p-0 overflow-hidden shadow-xs border-sky-100/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpcoming(showUpcoming === null ? false : !showUpcoming)}
|
||||
className="flex w-full items-center justify-between border-b border-slate-100 bg-sky-50/40 px-3.5 py-2.5 text-right transition-colors hover:bg-sky-50/70"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={15} className="text-sky-600 shrink-0" />
|
||||
<h2 className="text-xs font-bold text-slate-800">برنامه آتی و نقاط عطف کارگاه (هفته آینده)</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip tone="sky">{upcomingTasks.length} مورد</Chip>
|
||||
{(showUpcoming ?? true) ? (
|
||||
<ChevronUp size={16} className="text-slate-400" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{(showUpcoming ?? true) && (
|
||||
upcomingTasks.length > 0 ? (
|
||||
<div className="p-2.5 space-y-2 bg-slate-50/30">
|
||||
{upcomingTasks.map((t) => (
|
||||
<UpcomingTaskCard
|
||||
key={dashboardTaskKey(t)}
|
||||
task={t}
|
||||
projectName={activeProject.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-slate-400">
|
||||
هیچ برنامه یا فعالیت آتی برای هفته آینده در این پروژه ثبت نشده است.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user