Squashed 'pm/' content from commit 91f3271

git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
commit 934b74ebbd
87 changed files with 23922 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# PM — Construction Project Management Platform
# Copy to .env and customize as needed (never commit .env)
# Database credentials
PM_DB_PASSWORD=pm_secret_2026
# Session secret (random 32+ bytes hex string)
PM_SESSION_SECRET=change-me-32-bytes-hex
# Initial admin account
PM_ADMIN_USER=admin
PM_ADMIN_PASSWORD=pm123456
# ERP service account (Casdoor org `inventory` - for materials/warehouse integration)
PM_ERP_CASDOOR_CLIENT_ID=inventory-app-client
PM_ERP_CASDOOR_CLIENT_SECRET=inventory-app-secret-2026
PM_ERP_SERVICE_USERNAME=pm-service
PM_ERP_SERVICE_PASSWORD=pm-service-2026
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings to LF (mirrors the ERP repo convention)
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
*.ico binary
*.dump binary
+23
View File
@@ -0,0 +1,23 @@
# secrets & local state — never commit
.env
.env.local
# python
__pycache__/
*.pyc
.venv/
venv/
# node
node_modules/
dist/
*.local
# editor / OS
.vscode/
.idea/
Thumbs.db
.DS_Store
# uploaded defect photos (runtime volume)
files/
+102
View File
@@ -0,0 +1,102 @@
# PM API Contract (v3) — Standalone Native Platform — پایه: /api/v1
پلتفرم مدیریت پروژه ساختمانی بومی (Standalone)، مستقل و بدون وابستگی به هیچ سیستم خارجی.
## Auth
- `POST /auth/login` `{username, password}` → `{data:{token, user:{id, username, display_name, role}}}`
- `GET /auth/dev-roles` (در حالت توسعه) → لیست نقش‌های آماده جهت ورود سریع
- `POST /auth/dev-login` `{role}` (در حالت توسعه) → دریافت سریع توکن نقش انتخابی
- همه endpointها (جز لاگین و فایل‌های عمومی) هدر `Authorization: Bearer <token>` می‌خواهند.
- خطاها: `{"error": {"code", "message", "field"?}}` — کدها: `VALIDATION_ERROR` (400)،
`UNAUTHORIZED` (401)، `FORBIDDEN` (403)، `NOT_FOUND` (404)، `CONFLICT` (409)،
`UPSTREAM_UNAVAILABLE` (503).
## تاریخ جلالی
- **همه تاریخ‌های قابل مشاهده در UI شمسی‌اند.** API هم ISO می‌پذیرد/می‌دهد و هم شمسی.
- هر تسک علاوه بر `start_date`/`due_date` (ISO) فیلدهای `start_date_jalali`/
`due_date_jalali` را هم برمی‌گرداند («1405/06/23»).
- `PATCH /tasks/{id}/dates` هر دو قالب را می‌پذیرد: `1405/06/23` یا `2026-09-14`
(سال ≥ 1700 میلادی، کمتر از آن شمسی تفسیر می‌شود). روز نامعتبر → 400.
- تبدیل‌ها در `backend/app/utils/jalali.py` و `frontend/src/lib/jalali.ts` انجام می‌شوند.
## Dashboard
- `GET /dashboard/today` → `{data:{summary:{problems,warnings,ok}, sections:{overdue,due_today,awaiting_qc,failed_qc,blocked,upcoming}}}`
- `awaiting_qc` = وضعیت‌های `pre_qc_pending|exec_qc_pending|ready`
- `failed_qc` = وضعیت‌های `pre_qc_failed|qc_failed_rework`
## Projects
- `GET /projects` → `[...{id, name, identifier, description, active, erp_project_id, created_at, stats:{total, awaiting_qc, failed_qc, overdue}}]`
- `POST /projects` → ساخت پروژه جدید
- `GET /projects/{id}` → جزئیات پروژه به همراه آمارهای تسک‌ها
- `PUT /projects/{id}` | `DELETE /projects/{id}`
- `GET /projects/{id}/gantt` → داده‌های گانت چارت پروژه
- `PATCH /projects/{id}/erp-mapping {erp_project_id}`
## Tasks
- `GET /tasks?project_id=&search=&qc_status=&status=&page=&page_size=`
- `POST /tasks` → ثبت فعالیت/تسک جدید
- `GET /tasks/{id}` → تسک + `relations` + `children` + **`qc`** + **`qc_definitions`** + `defects` + `open_defects`
- `PUT /tasks/{id}` → ویرایش اطلاعات تسک
- `DELETE /tasks/{id}` → حذف تسک
- `PATCH /tasks/{id}/progress {percentage: 0..100}`
- **قاعده ۱:** اگر `percentage > 0` و بازرسی پیش از اجرا تأیید نشده باشد → 400
«بازرسی پیش از اجرا تأیید نشده است (N سؤال باقی‌مانده)»
- **قاعده ۲:** اگر `percentage >= 100` و هر سؤال الزامی تأییدنشده/مردود باشد → 400
«تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (…)»
- `PATCH /tasks/{id}/dates {start_date?, due_date?}` (ISO یا شمسی)
- `POST /tasks/relations` | `DELETE /tasks/relations/{id}` → مدیریت روابط وابستگی تسک‌ها (FS, SS, FF, SF)
### شکل بلوک `qc` در جزئیات تسک
```json
{"pre": {"total":3,"passed":3,"failed":0,"pending":0,"na":0,"required_unresolved":0,"status":"passed"},
"exec": {"total":6,"passed":2,"failed":1,"pending":3,"na":0,"required_unresolved":4,"status":"pending"},
"all": { /* مجموع */ },
"status": "exec_qc_pending",
"can_start": true, "can_complete": false,
"block_reasons": ["تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (۴ سؤال باقی‌مانده، ۱ مورد مردود)"]}
```
### وضعیت‌های `qc_status` روی تسک
`none` · `pre_qc_pending` · `pre_qc_failed` · `ready` · `exec_qc_pending` ·
`in_progress` · `qc_failed_rework` · `final_passed`
## QC — تعریف (Definition)
### قالب‌های سراسری (قابل استفاده مجدد)
- `GET /qc/templates` → `[{id,name,activity_type,description,questions:[{id,phase,text,sort_order,required}]}]`
- `POST /qc/templates {name, activity_type?, description?, questions?:[…]}` → `{data:{id}}`
- `PATCH /qc/templates/{id} {name?,activity_type?,description?}`
- `DELETE /qc/templates/{id}`
- `POST /qc/templates/{id}/questions {phase,text,required?,sort_order?}`
- `PATCH /qc/questions/{qid} {text?,phase?,required?,sort_order?}`
- `DELETE /qc/questions/{qid}`
- `PUT /qc/templates/{id}/questions {questions:[…]}` — جایگزینی گروهی (برای ادیتور)
`phase` ∈ `"pre"` (پیش از اجرا) · `"during"` (هنگام اجرا) · `"after"` (پس از اجرا)
### تعریف مخصوص تسک (کپی مستقل — ویرایش آن قالب سراسری را تغییر نمی‌دهد)
- `GET /qc/tasks/{task_id}` → `{data:{task, definitions:[{id,name,source_template_id,created_at,questions:[{…,result,history_count}],summary}], readiness}}`
- `POST /qc/tasks/{task_id}/definition {name?, template_id?}` → **کپی عمیق** سؤالات قالب
- `PATCH /qc/definitions/{id} {name}` | `DELETE /qc/definitions/{id}`
- `POST /qc/definitions/{id}/questions {text,phase,required?,sort_order?}`
- `PATCH /qc/task-questions/{qid} {text?,phase?,required?,sort_order?}`
- `DELETE /qc/task-questions/{qid}`
## QC — اجرا (Execution)
- `PUT /qc/task-questions/{qid}/result {answer:"pass"|"fail"|"na", comment?}`
→ `{data:{question_id, answer, task_qc_status, readiness}}`
- پاسخ‌ها **append-only** هستند (تاریخچه می‌ماند؛ بازرسی مجدد پس از رفع عیب ممکن است)
- تعریف و اجرا کاملاً جدا هستند: هنگام ساخت/کپی سؤال هیچ پاس/ردی ثبت نمی‌شود.
- عیب‌ها:
- `POST /qc/defects {task_id, question_id?, title, description?, severity?, corrective_action?, photo_id?}`
- `POST /qc/defects/{id}/resolve {resolution_notes?}`
- عکس عیب: `POST /files` (multipart) → `{data:{id, url}}` سپس `photo_id`
## فایل‌ها
- `POST /files` multipart (jpg/png/webp تا ۱۰MB) → `{data:{id,url,filename}}`
- `GET /files/{id}`
## Materials / Resources (یکپارچگی ERP)
- `GET/POST /tasks/{task_id}/materials` · `PATCH/DELETE /materials/{req_id}`
- `GET /erp/materials?search=` · `GET /erp/projects`
- `POST /materials/{req_id}/reserve|purchase-request` · `GET /reservations` · `POST /reservations/{id}/release`
- `GET/PATCH /purchase-requests` · `POST /projects/{project_id}/erp-mapping`
+174
View File
@@ -0,0 +1,174 @@
# راهنمای جامع معماری و مدل داده پایگاه‌داده سیستم مدیریت پروژه عمرانی (Construction PM)
> **دستورالعمل برای هوش مصنوعی (AI Prompt / Context):**
> این سند، مشخصات دقیق معماری، موجودیت‌ها (Entities)، فیلدها، روابط و قواعد تجاری یک سیستم بومی مدیریت پروژه عمرانی (Construction Project Management) است.
> لطفاً این راهنما را مطالعه کرده و بر اساس آن، ساختار سیستم، نحوه ذخیره‌سازی داده‌ها، جریان کار (Workflow) و روابط میان جداول را برای کاربر تحلیل و تشریح نما.
---
## ۱. نمای کلی سیستم و فناوری‌ها (Architecture Overview)
- **ماهیت سامانه:** پلتفرم یکپارچه و بومی مدیریت پروژه‌های عمرانی و ساختمانی (Native Construction PM Platform).
- **لایه‌های نرم‌افزاری:**
- **فرانت‌اند:** React + TypeScript + TailwindCSS + TanStack Query (رابط کاربری مدرن با پشتیبانی تقویم شمسی و نمودار گانت تعاملی).
- **بک‌اند:** Python 3.10+ با فریم‌ورک FastAPI و SQLAlchemy 2.0 (Async ORM با درایور aiosqlite / asyncpg).
- **پایگاه‌داده:** SQLite / PostgreSQL با ساختار کاملاً رابطه‌ای (Relational) و فیلدهای ساخت‌یافته JSON.
- **شناسه‌های یکتا (Primary Keys):** تمامی جداول از کلیدهای اصلی ۱۰۰٪ بومی از نوع `UUID v4` استفاده می‌کنند. هیچ‌گونه شناسه یا وابستگی به سیستم‌های قدیمی (Legacy) وجود ندارد.
- **منطق تاریخ‌ها:** تاریخ‌ها در پایگاه‌داده به صورت استاندارد میلادی (`Date` و `DateTime`) ذخیره می‌شوند و تبدیل‌های شمسی به‌صورت لایه‌ای و لحظه‌ای برای نمایش به کاربر انجام می‌گیرد.
---
## ۲. نمودار رابطه موجودیت‌ها (ERD)
```mermaid
erDiagram
PROJECTS ||--o{ TASKS : "شامل می‌شود (1:N)"
PROJECTS ||--o{ PROJECT_MEMBERS : "تخصیص اعضا (1:N)"
PM_USERS ||--o{ PROJECT_MEMBERS : "عضویت کاربر (1:N)"
PM_USERS ||--o{ PROJECT_ASSIGNMENTS : "دسترسی پروژه (1:N)"
PROJECTS ||--o{ PROJECT_ASSIGNMENTS : "محدوده دسترسی (1:N)"
PM_USERS ||--o{ TASKS : "مسئول وظیفه (Assignee)"
TASKS ||--o{ TASKS : "ساختار درختی WBS (والد/فرزند)"
TASKS ||--o{ TASK_RELATIONS : "وظیفه پیش‌نیاز (from_task)"
TASKS ||--o{ TASK_RELATIONS : "وظیفه پس‌نیاز (to_task)"
PROJECTS ||--o{ TASK_RELATIONS : "روابط گانت پروژه"
TASKS ||--o{ TASK_QC_DEFINITIONS : "چک‌لیست‌های کنترلی"
TASK_QC_DEFINITIONS ||--o{ TASK_QC_QUESTIONS : "آیتم‌های کنترلی"
TASK_QC_QUESTIONS ||--o{ TASK_QC_RESULTS : "تاریخچه بازرسی‌ها"
TASKS ||--o{ DEFECTS : "عیوب و عدم انطباق‌ها"
TASK_QC_QUESTIONS ||--o{ DEFECTS : "علت ریشه‌ای عیب (اختیاری)"
QC_TEMPLATES ||--o{ QC_QUESTIONS : "قالب‌های عمومی بازرسی"
```
---
## ۳. شرح تفصیلی جداول و موجودیت‌ها (Data Dictionary)
### ۱. جدول پروژه‌ها (`projects`)
موجودیت ریشه سامانه که اطلاعات اصلی کارگاه‌های عمرانی را نگهداری می‌کند.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه یکتای پروژه |
| `name` | `VARCHAR(255)` | Index | نام پروژه (مانند «پروژه برج بهارستان») |
| `description` | `TEXT` | - | شرح پروژه، الزامات و مشخصات فنی |
| `location` | `VARCHAR(255)` | - | موقعیت جغرافیایی / نشانی کارگاه |
| `project_manager` | `VARCHAR(255)` | - | نام مدیر یا سرپرست کارگاه |
| `status` | `VARCHAR(32)` | Index | وضعیت: `PLANNED`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELLED` |
| `planned_start` | `DATE` | - | تاریخ شروع مصوب (محاسبه و تجمیع خودکار از کمترین تاریخ شروع تسک‌ها) |
| `planned_end` | `DATE` | - | تاریخ پایان مصوب (محاسبه و تجمیع خودکار از بیشترین تاریخ پایان تسک‌ها) |
| `actual_start` | `DATE` | - | تاریخ شروع واقعی (محاسبه خودکار از زمان آغاز اولین تسک) |
| `actual_end` | `DATE` | - | تاریخ پایان واقعی (محاسبه خودکار با اتمام ۱۰۰٪ تمام تسک‌ها) |
| `progress` | `FLOAT` | - | درصد پیشرفت فیزیکی پروژه (میانگین وزنی درصد پیشرفت تسک‌ها از ۰ تا ۱۰۰) |
| `created_at` | `TIMESTAMP` | - | زمان ثبت رکورد |
| `updated_at` | `TIMESTAMP` | - | زمان آخرین ویرایش رکورد |
---
### ۲. جدول وظایف و فعالیت‌ها (`tasks`)
اصلی‌ترین موجودیت سامانه برای ساختار شکست کار (WBS)، گانت، تخصیص منابع و کنترل کیفی.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه یکتای وظیفه |
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه‌ای که وظیفه به آن تعلق دارد (Cascade Delete) |
| `parent_id` | `UUID` (Nullable) | **FK** -> `tasks.id` | والد در ساختار درختی WBS با عمق نامحدود (فاز ➔ فعالیت ➔ زیرتسک) |
| `title` | `VARCHAR(512)` | - | عنوان وظیفه (مانند «بتن‌ریزی سقف طبقه ۲») |
| `description` | `TEXT` | - | مشخصات و الزامات کار |
| `status` | `VARCHAR(32)` | Index | وضعیت: `TODO`, `IN_PROGRESS`, `BLOCKED`, `COMPLETED`, `CANCELLED` |
| `priority` | `VARCHAR(32)` | - | اولویت: `low`, `medium`, `high`, `urgent` |
| `assignee_id` | `UUID` (Nullable) | **FK** -> `pm_users.id` | کاربر سیستم مسئول اجرای وظیفه |
| `assignee_name` | `VARCHAR(255)` | - | نام متنی کاربر جهت نمایش سریع |
| `planned_start` | `DATE` | - | تاریخ شروع برنامه‌ریزی‌شده |
| `planned_end` | `DATE` | - | تاریخ پایان برنامه‌ریزی‌شده |
| `actual_start` | `DATE` (Nullable) | - | شروع واقعی (ثبت خودکار هنگام تغییر پیشرفت به بیشتر از صفر) |
| `actual_end` | `DATE` (Nullable) | - | پایان واقعی (ثبت خودکار در زمان تکمیل ۱۰۰٪) |
| `duration` | `INTEGER` | - | مدت زمان برآورد شده بر حسب روز کاری |
| `progress` | `INTEGER` | - | درصد پیشرفت واقعی از ۰ تا ۱۰۰ |
| `qc_status` | `VARCHAR(32)` | - | وضعیت کنترل کیفی: `none`, `pre_qc_pending`, `pre_qc_failed`, `ready`, `exec_qc_pending`, `in_progress`, `qc_failed_rework`, `final_passed` |
| `human_resources` | `JSON` | - | لیست نقش‌ها و نفرات: `[{"role": "آرماتوربند", "count": 4}]` |
| `materials` | `JSON` | - | لیست مصالح و تجهیزات/ماشین‌آلات: `[{"name": "سیمان تیپ ۲", "unit": "تن", "specifications": ["عیار ۳۵۰"], "count": 10}, {"name": "تاور کرین", "unit": "دستگاه", "specifications": ["ظرفیت ۸ تن"], "count": 1}]` |
| `created_at` | `TIMESTAMP` | - | تاریخ ایجاد رکورد |
| `updated_at` | `TIMESTAMP` | - | تاریخ آخرین به‌روزرسانی |
---
### ۳. جدول روابط وابستگی و گانت (`task_relations`)
پایه‌گذار روش مسیر بحرانی (CPM Scheduling) و شبکه فعالیت‌های گانت.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه رابطه |
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه مربوطه |
| `from_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت پیش‌نیاز (Predecessor) |
| `to_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت وابسته / پس‌نیاز (Successor) |
| `relation_type` | `VARCHAR(8)` | - | نوع وابستگی: `FS` (پایان به شروع)، `SS` (شروع به شروع)، `FF` (پایان به پایان)، `SF` (شروع به پایان) |
| `lag_days` | `INTEGER` | - | فاصله زمانی مجاز (تاخیر یا تقدم) بر حسب روز |
| `created_at` | `TIMESTAMP` | - | زمان ثبت رابطه |
*محدودیت (Constraint):* زوج `(from_task_id, to_task_id)` یکتا بوده و از ایجاد حلقه (Cycle) در الگوریتم CPM جلوگیری می‌شود.
---
### ۴. حوزه کنترل کیفیت و بازرسی فنی (QC Domain)
این بخش تضمین می‌کند کلیه اقدامات اجرایی کارگاه دارای تاییدیه‌های استاندارد باشند:
#### الف) قالب‌های سراسری بازرسی (`qc_templates` و `qc_questions`)
- **`qc_templates`:** کتابخانه فرم‌های استاندارد کیفی (مثلاً قالب بازرسی بتن‌ریزی، قالب سفت‌کاری).
- **`qc_questions`:** سوالات و ضوابط کنترلی در سه فاز اجرایی:
- `pre`: پیش از اجرا (آمادگی کارگاه، تایید نقشه‌ها، مصالح ورودی).
- `during`: حین اجرا (دمای بتن، ویبره مناسب، ایمنی).
- `after`: پس از اجرا (عمل‌آوری، مقاومت فشاری نمونه‌ها).
#### ب) فرم‌های کیفی اختصاص‌یافته به وظایف (`task_qc_definitions` و `task_qc_questions`)
- با تخصیص چک‌لیست به یک تسک، یک نسخه مستقل از قالب بر روی وظیفه ایجاد می‌شود تا هرگونه تغییر روی قالب سراسری به تاریخچه بازرسی‌های ثبت‌شده آسیب نزند.
#### ج) نتایج و تاریخچه بازرسی (`task_qc_results`)
- سیستم ثبت پیوسته (Append-Only Log) بدون امکان حذف؛ هر ارزیابی بازرس شامل:
- `question_id`: شناسه آیتم بازرسی.
- `answer`: پاسخ بازرس (`pass` تایید، `fail` عدم انطباق، `na` عدم کاربرد).
- `comment`: توضیحات فنی و نواقص مشاهده‌شده.
- `evaluated_by`: نام بازرس ناظر.
- `evaluated_at`: زمان دقیق بازرسی.
#### د) جدول عیوب و عدم انطباق‌ها (`defects`)
- ثبت رخدادهای نامنطبق در جریان اجرای کار:
- `id` (UUID - PK): شناسه عیب.
- `task_id` (UUID - FK -> `tasks.id`): وظیفه مربوطه.
- `question_id` (UUID - FK اختیاری): سوال بازرسی که منجر به رد شدن شده است.
- `title`: عنوان عدم انطباق (مثلاً: «کرموشدگی بتن ستون جنوبی»).
- `description`: شرح دقیق ایراد.
- `severity`: درجه شدت عیب (`minor`, `medium`, `major`, `critical`).
- `status`: وضعیت رفع (`open`, `in_progress`, `resolved`).
- `corrective_action`: دستور اقدام اصلاحی صادرشده توسط مهندس ناظر.
- `resolution_notes`: شرح نحوه رفع عیب.
- `photo_path`: مسیر ذخیره تصویر مستند عیب در سرور.
- `resolved_at`: زمان تایید رفع عیب.
---
### ۵. احراز هویت و دسترسی‌ها (`pm_users`, `project_members`, `project_assignments`)
- **`pm_users`:** کاربران سازمانی سیستم (`username`, `password_hash`, `display_name`, `role`).
- نقش‌های سیستمی: `project_manager` (دسترسی نامحدود به تمام پروژه‌ها)، `project_supervisor` (دسترسی محدود به پروژه‌های مشخص).
- **`project_members`:** اعضای منتسب به هر پروژه با نقش کارگاهی:
- `role`: `project_manager`, `site_engineer`, `qc_inspector`, `member`.
- **`project_assignments`:** جدول دامنه دسترسی برای ناظران و سرپرستان دارای دسترسی محدود (Scoped Roles).
---
## ۴. قواعد و منطق‌های تجاری نهفته در سیستم (Business Invariants)
1. **گیتینگ کنترل کیفی (QC Gating Rule):**
اگر وظیفه‌ای دارای چک‌لیست بازرسی فاز `pre` (پیش از اجرا) باشد، تا زمانی که تمامی این سوالات وضعیت `pass` یا `na` دریافت نکنند، سیستم اجازه شروع کار (`IN_PROGRESS`) و افزایش پیشرفت را نمی‌دهد.
2. **محاسبات پیش‌بینی مسیر بحرانی (CPM Logic):**
محاسبات زمان‌بندی با عبور رفت و برگشت (Forward & Backward Pass) تاریخ‌های زودهنگام و دیرهنگام را استخراج کرده و فعالیت‌های با شناوری صفر (`Total Float = 0`) را به عنوان مسیر بحرانی مشخص می‌کند.
3. **چرخه حیات خودکار تاریخ‌های واقعی:**
با اولین تغییر در پیشرفت تسک (`progress > 0`)، فیلد `actual_start` به تاریخ جاری تنظیم می‌گردد. با رسیدن پیشرفت به `100%`، فیلد `actual_end` تنظیم و وضعیت وظیفه خودکار به `COMPLETED` تغییر می‌یابد.
4. **تجمیع پروژه (Rollup Logic):**
تاریخ شروع، پایان و درصد پیشرفت فیزیکی کل پروژه همواره حاصل تجمیع و میانگین‌گیری پویای وظایف موجود در پروژه است.
+91
View File
@@ -0,0 +1,91 @@
# PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت
اپلیکیشن موبایل‌محور و تحت وب برای مدیریت جامع پروژه‌های عمرانی و ساخت: مدیریت سلسله‌مراتب فعالیت‌ها (WBS)، زمان‌بندی و گانت چارت، مسیر بحرانی (CPM)، سیستم پیشرفته کنترل کیفیت (QC)، مدیریت مصالح و انبار (با اتصال به 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 |
**ورود پیش‌فرض:** `admin` / `pm123456` (قابل تغییر از طریق فایل `.env`)
---
## معماری سامانه
```
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 Platform** | مدیریت کامل در دیتابیس بومی PM |
| چک‌لیست‌ها و کنترل کیفیت (QC) | **PM Platform** | پیش از اجرا، حین اجرا و پس از اجرا |
| ثبت عیوب و اقدامات اصلاحی | **PM Platform** | الصاق تصویر، شدت عیب، پیگیری رفع |
| کاتالوگ مصالح و موجودی واقعی انبار | **ERP** | استعلام برخط موجودی از طریق API |
| رزرو مصالح و درخواست‌های خرید | **PM Platform** | رزرو نرم بدون دستکاری انبار تا زمان تأیید |
---
## قابلیت‌های کلیدی
### ۱. مدیریت زمان‌بندی و گانت چارت (CPM Engine)
- ایجاد پروژه‌ها با کدهای اختصاصی و تیم پروژه
- ساختار شکست کار (WBS) در عمق‌های نامحدود (فاز، بخش، فعالیت، زیرفعالیت)
- وابستگی‌های چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) همراه با روزهای تأخیر (Lag)
- محاسبه خودکار مسیر بحرانی (Critical Path) و شناوری کل فعالیت‌ها
- تعریف مایلستون‌ها و نقاط عطف کلیدی پروژه
### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC)
- **مراحل ارزیابی:** پیش از اجرا (`pre`) · حین اجرا (`during`) · پس از اجرا (`after`)
- **قوانین دروازه‌ای سرور:**
- فعالیت تا زمان تأیید الزامات پیش از اجرا اجازه شروع (پیشرفت > ۰) ندارد.
- فعالیت تا زمان پاس شدن همه الزامات الزامی اجازه رسیدن به ۱۰۰٪ ندارد.
- ثبت عیوب همراه با تصاویر و الزام به بازرسی مجدد پس از اصلاح.
### ۳. مدیریت منابع و ارتباط با ERP
- تخصیص نفرات، ماشین‌آلات و مصالح به تفکیک فعالیت
- اتصال پروژه به پروژه متناظر در ERP جهت استعلام موجودی انبارهای کارگاه
- رزرو نرم مصالح جهت جلوگیری از تداخل مصرف در بخش‌های مختلف پروژه
### ۴. تقویم و تاریخ‌های شمسی
- تقویم بومی جلالی بدون خطا و با رعایت روزهای کاری
- ثبت و نمایش روان تاریخ‌ها در رابط کاربری به زبان فارسی
---
## راه‌اندازی و اجرا
اجرای آسان با اسکریپت پاورشل یا داکر کامپوز:
```powershell
# اجرای خودکار از پوشه PM:
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
یا مستقیماً با داکر:
```powershell
docker compose up -d --build
```
---
## قرارداد API
مستندات کامل اندپوینت‌ها: [`API_CONTRACT.md`](API_CONTRACT.md)
+3
View File
@@ -0,0 +1,3 @@
__pycache__
*.pyc
.pytest_cache
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.13-slim
WORKDIR /srv/pm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY tools ./tools
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""PM application package."""
+296
View File
@@ -0,0 +1,296 @@
"""Central authorization (RBAC) — roles, permissions and per-project scoping.
Everything authorization-related lives in this module so that adding a role is
one entry in `ROLE_PERMISSIONS` (plus a nav entry on the frontend), not a hunt
through the routers.
Two independent questions, answered separately:
* **Role → permissions** (`Perm`, `ROLE_PERMISSIONS`) decides *which operations*
a user may call at all.
* **Project scope** decides *which projects* those operations may touch. A role
listed in `SCOPED_ROLES` is limited to the rows in `project_assignments`
(an empty set means "no project at all"); every other role is unrestricted.
Routers must go through the dependencies and helpers below — hiding a button in
the UI is a convenience, never the enforcement.
"""
from __future__ import annotations
import enum
import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from fastapi import Depends
from sqlalchemy import Select, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.errors import ForbiddenError, NotFoundError
from app.models import PMUser, ProjectAssignment, Task
from app.security import get_current_user
logger = logging.getLogger("pm.authz")
# --- roles --------------------------------------------------------------------
class Role(str, enum.Enum):
project_manager = "project_manager"
project_supervisor = "project_supervisor"
ROLE_LABELS: dict[str, str] = {
Role.project_manager.value: "مدیر پروژه",
Role.project_supervisor.value: "سرپرست پروژه",
}
# Users created before RBAC existed keep their previous (full) access.
DEFAULT_ROLE = Role.project_manager
# Roles whose access is limited to their assigned projects.
SCOPED_ROLES: frozenset[Role] = frozenset({Role.project_supervisor})
# --- permissions --------------------------------------------------------------
class Perm(str, enum.Enum):
"""One value per authorizable operation — keep it coarse and stable."""
dashboard_view = "dashboard.view"
projects_view = "projects.view"
projects_create = "projects.create"
projects_update = "projects.update"
tasks_view = "tasks.view"
tasks_create = "tasks.create"
tasks_update = "tasks.update"
tasks_delete = "tasks.delete"
tasks_progress = "tasks.progress"
tasks_dates = "tasks.dates"
qc_view = "qc.view"
# executing QC: recording answers and raising/resolving defects
qc_execute = "qc.execute"
# QC structure: global templates + per-task definitions
qc_define = "qc.define"
materials_view = "materials.view"
materials_manage = "materials.manage"
users_manage = "users.manage"
files_upload = "files.upload"
# Project manager: full access to everything, across every project.
_MANAGER_PERMISSIONS: frozenset[str] = frozenset(p.value for p in Perm)
# Project supervisor: executes the assigned projects — reads the plan, records
# progress and QC results, needs the material list; never edits the QC
# structure, templates, procurement or integration settings.
_SUPERVISOR_PERMISSIONS: frozenset[str] = frozenset(
{
Perm.dashboard_view.value,
Perm.projects_view.value,
Perm.tasks_view.value,
Perm.tasks_progress.value,
Perm.qc_view.value,
Perm.qc_execute.value,
Perm.materials_view.value,
Perm.files_upload.value,
}
)
ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
Role.project_manager: _MANAGER_PERMISSIONS,
Role.project_supervisor: _SUPERVISOR_PERMISSIONS,
}
def permissions_for(role: Role) -> frozenset[str]:
return ROLE_PERMISSIONS.get(role, frozenset())
def role_of(user: PMUser) -> Role:
try:
return Role(user.role)
except ValueError:
logger.warning(
"unknown role %r on user %s — falling back to %s", user.role, user.username, DEFAULT_ROLE.value
)
return DEFAULT_ROLE
# --- request context ----------------------------------------------------------
@dataclass(frozen=True)
class AuthContext:
"""Who is calling, what they may do, and which projects they may touch.
`project_uuids` — native projects (`projects.id`).
None for an unscoped role (project manager). For a scoped role a set — possibly empty — means "only these".
"""
user: PMUser
role: Role
project_uuids: frozenset[uuid.UUID] | None = None
@property
def permissions(self) -> frozenset[str]:
return permissions_for(self.role)
@property
def role_label(self) -> str:
return ROLE_LABELS.get(self.role.value, self.role.value)
@property
def all_projects(self) -> bool:
return self.project_uuids is None
def can(self, permission: Perm | str) -> bool:
value = permission.value if isinstance(permission, Perm) else permission
return value in self.permissions
def require(self, permission: Perm | str) -> None:
if not self.can(permission):
raise ForbiddenError(f"نقش «{self.role_label}» به این عملیات دسترسی ندارد")
def allows_project(self, project_id) -> bool:
"""Check whether the user is allowed to access the specified project."""
if self.project_uuids is None:
return True
try:
pid = uuid.UUID(str(project_id))
except (ValueError, AttributeError):
return False
return pid in self.project_uuids
def ensure_project(self, project_id) -> None:
"""Raise 403 when the project is outside this user's scope."""
if not self.allows_project(project_id):
raise ForbiddenError("این پروژه به شما اختصاص داده نشده است")
allows_native_project = allows_project
ensure_native_project = ensure_project
def scope(self, stmt: Select, column) -> Select:
"""Restrict a query over project UUIDs."""
if self.project_uuids is None:
return stmt
return stmt.where(column.in_(self.project_uuids))
scope_native = scope
@property
def scoped_project_count(self) -> int:
"""How many projects a scoped user may see (0 when nothing is assigned)."""
return len(self.project_uuids or ())
def as_dict(self) -> dict:
return {
"role": self.role.value,
"role_label": self.role_label,
"permissions": sorted(self.permissions),
"scope": {
"all_projects": self.all_projects,
"project_count": None if self.all_projects else self.scoped_project_count,
},
}
async def assigned_scope(db: AsyncSession, user_id) -> frozenset[uuid.UUID]:
"""Load assigned project UUIDs for a scoped user."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
assigned = set(r for r in rows if r is not None)
# Also resolve projects assigned via projects.pm_user_id or user_project_members in the shared DB
pm_user = await db.get(PMUser, user_id)
if pm_user and pm_user.username:
erp_rows = (
await db.execute(
text("""
SELECT p.id FROM projects p JOIN users u ON p.pm_user_id = u.id WHERE u.username = :username
UNION
SELECT upm.project_id FROM user_project_members upm JOIN users u ON upm.user_id = u.id WHERE u.username = :username
"""),
{"username": pm_user.username},
)
).scalars().all()
for r in erp_rows:
if r:
try:
assigned.add(uuid.UUID(str(r)))
except (ValueError, TypeError):
pass
return frozenset(assigned)
async def build_context(db: AsyncSession, user: PMUser) -> AuthContext:
"""Resolve role + project scope for a user."""
role = role_of(user)
if role not in SCOPED_ROLES:
return AuthContext(user=user, role=role)
native = await assigned_scope(db, user.id)
return AuthContext(user=user, role=role, project_uuids=native)
async def get_auth_context(
user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> AuthContext:
return await build_context(db, user)
def require_permission(permission: Perm) -> Callable[..., Awaitable[AuthContext]]:
"""Dependency: authenticated **and** allowed to perform `permission`."""
async def _guard(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
ctx.require(permission)
return ctx
return _guard
# --- data-access guards -------------------------------------------------------
async def resolve_task(db: AsyncSession, task_ref: str | uuid.UUID) -> Task | None:
"""Find a native task by UUID."""
task_ref = task_ref.strip() if isinstance(task_ref, str) else task_ref
try:
task_uuid = uuid.UUID(str(task_ref))
except (ValueError, AttributeError):
return None
return await db.scalar(select(Task).where(Task.id == task_uuid))
async def scoped_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> Task:
"""Load a native task: 404 unknown, 403 out of scope."""
task = await resolve_task(db, task_ref)
if task is None:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_project(task.project_id)
return task
scoped_native_task = scoped_task
resolve_native_task = resolve_task
# --- development role switching ----------------------------------------------
def dev_switch_enabled() -> bool:
return bool(settings.DEV_ROLE_SWITCH)
def dev_login_usernames() -> list[str]:
"""Demo accounts the dev switcher may log in as (never arbitrary users)."""
return [name for name in settings.dev_demo_usernames() if name]
+184
View File
@@ -0,0 +1,184 @@
"""Casdoor SSO for PM — one identity source for the whole suite.
The Casdoor JWT issued by the ERP login flow is accepted on every PM endpoint:
- The token is verified against Casdoor's JWKS (same as the ERP backend):
signature (RS256 family), issuer = ``PM_CASDOOR_ISSUER`` (the browser-facing
origin Casdoor advertises — ``casdoor.origin`` in the compose env) and
audience = ``PM_CASDOOR_AUDIENCE`` (the inventory-app client).
- The caller's system-wide role lives in the ERP (``users.role``) and is
resolved by calling ``GET {ERP_BASE_URL}/api/v1/users/me`` with the SAME
Bearer token (the ERP verifies the identical JWT). Result is cached briefly
per casdoor id — one upstream call per role change window, not per request.
- Mapping to the PM world (single source of truth stays the ERP):
admin -> project_manager (full PM access)
pm (مدیر پروژه) -> project_manager (full PM access)
supervisor -> project_supervisor (project-scoped, as today)
procurement_manager -> 403 — مدیر تدارکات به پنل PM دسترسی ندارد
- The PMUser row is auto-provisioned / role-synced by username on first use,
so تخصیص سرپرست‌ها به پروژه‌ها (ProjectAssignment) بلافاصله بعد از اولین
ورود قابل انجام است.
Legacy HMAC session tokens (POST /auth/login, dev role switcher, standalone
:8083 deployments) keep working: ``get_current_user`` dispatches on token
shape (two dot-separated segments = legacy, three = Casdoor JWT).
"""
import logging
import time
import httpx
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.errors import AuthError, ForbiddenError, UpstreamError
from app.models import PMUser
# نقش‌های PM به‌صورت متن (PMUser.role ستون متنی است) — import از authz اینجا
# چرخه‌ی import می‌سازد (authz → security → casdoor_auth → authz)
_PM_PROJECT_MANAGER = "project_manager"
_PM_PROJECT_SUPERVISOR = "project_supervisor"
logger = logging.getLogger("pm.casdoor")
_JWKS_CACHE: dict | None = None
# casdoor_id -> (erp_role, expiry monotonic seconds)
_ROLE_CACHE: dict[str, tuple[str, float]] = {}
_ROLE_CACHE_TTL_SECONDS = 30
def _jwks_url() -> str:
return f"{settings.ERP_CASDOOR_ENDPOINT.rstrip('/')}/.well-known/jwks"
async def _get_jwks() -> dict:
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
except httpx.HTTPError as exc:
logger.error("Casdoor JWKS fetch failed (%s): %s", _jwks_url(), exc)
raise UpstreamError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc
return _JWKS_CACHE
def _key_from_jwk(jwk: dict):
kty = jwk.get("kty")
if kty == "RSA":
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
if kty == "EC":
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
return jwk
async def verify_casdoor_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims (AuthError on failure)."""
try:
kid = jwt.get_unverified_header(token).get("kid")
except jwt.PyJWTError as exc:
logger.warning("JWT unreadable: %s", exc)
raise AuthError("توکن نامعتبر است") from exc
jwks = await _get_jwks()
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if entry is None:
logger.warning("JWT kid %r not found in JWKS", kid)
raise AuthError("امضای توکن نامعتبر است")
key = _key_from_jwk(entry)
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
issuer=settings.CASDOOR_ISSUER,
audience=settings.CASDOOR_AUDIENCE,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise AuthError("توکن نامعتبر یا منقضی شده است") from exc
async def _erp_identity_for_token(token: str, casdoor_id: str) -> dict:
"""نقش + پنل‌های کاربر از ERP (users/me) — کش کوتاه."""
cached = _ROLE_CACHE.get(casdoor_id)
if cached and cached[1] > time.monotonic():
return cached[0]
url = f"{settings.ERP_BASE_URL.rstrip('/')}/api/v1/users/me"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
logger.error("ERP /users/me unreachable (%s): %s", url, exc)
raise UpstreamError("سامانه تدارکات (ERP) برای تعیین دسترسی در دسترس نیست") from exc
if resp.status_code == 401:
raise AuthError("توکن نامعتبر یا منقضی شده است")
if resp.status_code != 200:
logger.error("ERP /users/me failed (%s): HTTP %s", url, resp.status_code)
raise UpstreamError("تعیین دسترسی کاربر از ERP ناموفق بود")
data = resp.json().get("data") or {}
identity = {"role": data.get("role"), "panels": data.get("panels") or {}}
_ROLE_CACHE[casdoor_id] = (identity, time.monotonic() + _ROLE_CACHE_TTL_SECONDS)
return identity
# سطح پنل PM -> نقش PM: full = همه بخش‌ها با اختیار کامل تغییر؛
# limited = پاسخ QC + درصد پیشرفت + مشاهده جزئیات (محدود به پروژه‌های تخصیصی).
# مدیر تدارکات به PM دسترسی ندارد.
async def casdoor_user_from_token(token: str, db: AsyncSession) -> PMUser:
"""Verify the JWT, resolve the system role from ERP and return the
provisioned PMUser (role synced). 403 for roles without PM access."""
claims = await verify_casdoor_token(token)
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
username = str(claims.get("name") or claims.get("preferred_username") or "")
if not casdoor_id or not username:
raise AuthError("توکن فاقد شناسه کاربر است")
identity = await _erp_identity_for_token(token, casdoor_id)
panels = identity.get("panels") or {}
erp_role = identity.get("role")
pm_level = panels.get("pm")
if erp_role == "admin":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "full":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "limited":
pm_role = _PM_PROJECT_SUPERVISOR
elif not panels and erp_role in ("admin", "pm"): # میراث — دوره مهاجرت فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_MANAGER
elif not panels and erp_role == "supervisor": # میراث — فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_SUPERVISOR
else:
raise ForbiddenError("به پنل مدیریت پروژه دسترسی ندارید")
user = await db.scalar(select(PMUser).where(PMUser.username == username))
if user is None:
display_name = str(
claims.get("displayName") or claims.get("firstName") or username
)
user = PMUser(
username=username,
password_hash="", # SSO identities never authenticate locally
display_name=display_name,
role=pm_role,
)
db.add(user)
await db.commit()
await db.refresh(user)
elif user.role != pm_role:
# نقش سراسری عوض شده — با PM همگام می‌شود
user.role = pm_role
await db.commit()
await db.refresh(user)
return user
def is_casdoor_token(token: str) -> bool:
"""Legacy session tokens are ``body.signature`` (one dot); Casdoor JWTs
have the standard three header/payload/signature segments."""
return token.count(".") == 2
+49
View File
@@ -0,0 +1,49 @@
"""PM settings — env prefix PM_ (mirrors the ERP PROCUREMENT_ pattern)."""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PM_", env_file=".env", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://pm:pm@localhost:5432/pm"
SESSION_SECRET: str = "change-me"
SESSION_TTL_HOURS: int = 24 * 14
ADMIN_USER: str = "admin"
ADMIN_PASSWORD: str = "admin"
# Development role switching — the dashboard shows a role picker so the two
# roles can be tried without separate logins. Set PM_DEV_ROLE_SWITCH=false
# (production) to disable the dev endpoints entirely.
DEV_ROLE_SWITCH: bool = False
DEV_SUPERVISOR_USER: str = "supervisor"
DEV_SUPERVISOR_PASSWORD: str = "pm123456"
DEV_SUPERVISOR2_USER: str = "supervisor2"
DEV_SUPERVISOR2_PASSWORD: str = "pm123456"
def dev_demo_usernames(self) -> list[str]:
"""Accounts the dev role switcher may log in as (manager + supervisors)."""
return [self.ADMIN_USER, self.DEV_SUPERVISOR_USER, self.DEV_SUPERVISOR2_USER]
FILES_DIR: Path = Path("/data/files")
# ERP (procurement/inventory) integration — read-only service account
ERP_BASE_URL: str = "http://procurement-api:8000"
ERP_CASDOOR_ENDPOINT: str = "http://casdoor:8000"
ERP_CASDOOR_CLIENT_ID: str = "inventory-app-client"
ERP_CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
ERP_CASDOOR_ORGANIZATION: str = "inventory"
ERP_SERVICE_USERNAME: str = "pm-service"
ERP_SERVICE_PASSWORD: str = ""
ERP_CACHE_TTL_SECONDS: int = 120
# Casdoor SSO — PM accepts the ERP-issued JWT as its own session.
# ISSUER = the browser-facing origin Casdoor advertises (casdoor `origin`),
# not the in-network endpoint; AUDIENCE = the inventory-app client id.
CASDOOR_ISSUER: str = "http://localhost:8080"
CASDOOR_AUDIENCE: str = "inventory-app-client"
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
"""Async SQLAlchemy engine/session for PM database."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
try:
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_size=5, max_overflow=5)
except Exception:
# Fallback for testing environments or when asyncpg driver is not installed on host
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
+151
View File
@@ -0,0 +1,151 @@
"""Development-only demo accounts for the role switcher on the dashboard.
While the two roles are being built, the «امروز» dashboard shows a role picker
so nobody has to juggle logins. This module creates those demo users and gives
the demo supervisors a project scope, so the picker is immediately useful —
the enforcement itself is the real thing (see app/authz.py).
Everything here is inert unless `PM_DEV_ROLE_SWITCH` is enabled; the endpoints
in routers/auth.py return 404 in that case. For production set the flag to
false and manage users through `PUT /users/{id}/projects`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import Role, dev_switch_enabled
from app.config import settings
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
logger = logging.getLogger("pm.dev")
@dataclass(frozen=True)
class DemoAccount:
username: str
password: str
display_name: str
role: Role
share: tuple[int, int] | None = None
def demo_accounts() -> list[DemoAccount]:
total = len(settings.dev_demo_usernames())
return [
DemoAccount(settings.ADMIN_USER, settings.ADMIN_PASSWORD, "مدیر سیستم", Role.project_manager),
DemoAccount(
settings.DEV_SUPERVISOR_USER,
settings.DEV_SUPERVISOR_PASSWORD,
"سرپرست پروژه الف",
Role.project_supervisor,
share=(1, 2),
),
DemoAccount(
settings.DEV_SUPERVISOR2_USER,
settings.DEV_SUPERVISOR2_PASSWORD,
"سرپرست پروژه ب",
Role.project_supervisor,
share=(2, 2),
),
][:total]
def _share_of(items: list, share: tuple[int, int]) -> list:
"""Deterministic, non-overlapping slice: part N of M (1-based)."""
part, of = share
size = -(-len(items) // of)
start = (part - 1) * size
return items[start : start + size]
def demo_account(username: str) -> DemoAccount | None:
for account in demo_accounts():
if account.username == username:
return account
return None
async def ensure_demo_accounts(db: AsyncSession) -> None:
if not dev_switch_enabled():
# در پروداکشن، حساب‌های نمایشی توسعه نباید فعال باشند
demo_names = [settings.DEV_SUPERVISOR_USER, settings.DEV_SUPERVISOR2_USER]
demo_users = (
await db.execute(select(PMUser).where(PMUser.username.in_(demo_names)))
).scalars().all()
for u in demo_users:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == u.id))
await db.delete(u)
logger.info("cleaned up demo account %s for production", u.username)
admin_u = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if admin_u is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role=Role.project_manager.value,
)
)
logger.info("seeded admin account in production mode")
else:
# همیشه رمز عبور را از تنظیمات به‌روز کن تا لاگین همیشه کار کند
admin_u.password_hash = hash_password(settings.ADMIN_PASSWORD)
admin_u.role = Role.project_manager.value
logger.info("refreshed admin password hash in production mode")
await db.commit()
return
for account in demo_accounts():
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
db.add(
PMUser(
username=account.username,
password_hash=hash_password(account.password),
display_name=account.display_name,
role=account.role.value,
)
)
logger.info("seeded %s account %s", account.role.value, account.username)
else:
user.role = account.role.value
user.display_name = account.display_name
await db.commit()
async def ensure_demo_assignments(db: AsyncSession) -> None:
if not dev_switch_enabled():
return
native_ids = [p for p in (await db.execute(select(Project.id).order_by(Project.name))).scalars().all()]
if not native_ids:
return
for account in demo_accounts():
if account.share is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
existing = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
if existing:
continue
mine = _share_of(native_ids, account.share)
for project_uuid in mine:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
logger.info(
"assigned %d demo projects to %s",
len(mine),
account.username,
)
await db.commit()
async def clear_assignments(db: AsyncSession, user_id) -> None:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == user_id))
+53
View File
@@ -0,0 +1,53 @@
"""PM error envelope — same contract as the ERP: {"error": {code, message, field}}."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
status_code = 400
code = "BAD_REQUEST"
def __init__(self, message: str, field: str | None = None, code: str | None = None):
super().__init__(message)
self.message = message
self.field = field
if code:
self.code = code
@property
def body(self) -> dict:
error = {"code": self.code, "message": self.message}
if self.field is not None:
error["field"] = self.field
return {"error": error}
class AuthError(AppError):
status_code, code = 401, "UNAUTHORIZED"
class ValidationError(AppError):
status_code, code = 400, "VALIDATION_ERROR"
class ForbiddenError(AppError):
status_code, code = 403, "FORBIDDEN"
class NotFoundError(AppError):
status_code, code = 404, "NOT_FOUND"
class ConflictError(AppError):
status_code, code = 409, "CONFLICT"
class UpstreamError(AppError):
"""Upstream service unreachable / errored."""
status_code, code = 503, "UPSTREAM_UNAVAILABLE"
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.body)
+1
View File
@@ -0,0 +1 @@
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
+160
View File
@@ -0,0 +1,160 @@
"""ERP (procurement/inventory) read-only connector.
Auth: Casdoor password grant with a dedicated service account (no changes to
the ERP codebase). The token is cached until expiry. ERP stays the source of
truth for physical stock; PM only READS it (reservations/purchase requests are
PM-owned operational overlays).
"""
import asyncio
import logging
import time
from typing import Any
import httpx
from app.config import settings
from app.errors import UpstreamError
logger = logging.getLogger("pm.erp")
class ERPClient:
def __init__(self):
self._token: str | None = None
self._token_exp: float = 0
self._lock = asyncio.Lock()
async def _ensure_token(self) -> str:
async with self._lock:
if self._token and time.time() < self._token_exp - 30:
return self._token
usernames = [
settings.ERP_SERVICE_USERNAME,
f"{settings.ERP_CASDOOR_ORGANIZATION}/{settings.ERP_SERVICE_USERNAME}",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=12) as client:
for username in usernames:
try:
resp = await client.post(
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token",
data={
"grant_type": "password",
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
"username": username,
"password": settings.ERP_SERVICE_PASSWORD,
},
)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if token:
self._token = token
self._token_exp = time.time() + int(data.get("expires_in") or 3600)
return token
except Exception as exc: # noqa: BLE001
last_error = exc
logger.warning("ERP token grant failed for %s: %s", username, exc)
raise UpstreamError(f"احراز هویت سرویس با ERP/Casdoor ناموفق بود: {last_error}")
async def get(self, path: str, params: dict | None = None) -> Any:
token = await self._ensure_token()
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{settings.ERP_BASE_URL}{path}",
params=params,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise UpstreamError(f"ERP در دسترس نیست: {exc.__class__.__name__}") from exc
if resp.status_code == 401:
self._token = None
raise UpstreamError("توکن سرویس ERP نامعتبر شد")
if resp.status_code >= 400:
logger.warning("ERP GET %s → %s %s", path, resp.status_code, resp.text[:200])
raise UpstreamError(f"خطای ERP ({resp.status_code})")
if not resp.content:
return {}
return resp.json()
# --- domain reads (normalize to PM-friendly dicts) -----------------------
async def materials(self) -> list[dict]:
"""Global material catalog: [{id, name, unit_id, unit_name}]"""
materials: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/materials?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
materials.extend(elements)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(materials)):
break
page += 1
unit_names: dict[int, str] = {}
try:
units = await self.get("/api/v1/units?page_size=100")
for u in units.get("data", []):
unit_names[u.get("id")] = u.get("name_fa") or u.get("name") or u.get("code") or ""
except Exception: # noqa: BLE001 — unit names are cosmetic
pass
return [
{
"id": m.get("id"),
"name": m.get("name") or "",
"unit_id": m.get("canonical_unit_id"),
"unit_name": unit_names.get(m.get("canonical_unit_id"), ""),
}
for m in materials
if m.get("id") and (m.get("status") or "active") == "active"
]
async def projects(self) -> list[dict]:
"""ERP projects for the mapping picker: [{id, name}]"""
data = await self.get("/api/v1/projects?page_size=100")
elements = data.get("data", []) if isinstance(data, dict) else []
return [{"id": p.get("id"), "name": p.get("name") or ""} for p in elements if p.get("id")]
async def warehouses(self, erp_project_id: str) -> list[dict]:
data = await self.get("/api/v1/inventory/warehouses")
elements = data.get("data", data) if isinstance(data, dict) else data
if isinstance(elements, dict):
elements = elements.get("data", [])
return [w for w in (elements or []) if w.get("project_id") == erp_project_id]
async def inventory_rows(self) -> list[dict]:
"""Normalized stock rows: [{warehouse_id, material_id, quantity}].
ERP returns nested objects (warehouse: {...}, material: {...}) plus a
numeric `quantity_number` — flatten to the three fields PM needs.
"""
rows: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/inventory?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
for r in elements:
warehouse = r.get("warehouse") or {}
material = r.get("material") or {}
qty = r.get("quantity_number")
if qty is None:
qty = r.get("quantity")
rows.append(
{
"warehouse_id": warehouse.get("id"),
"material_id": material.get("id"),
"quantity": float(qty or 0),
}
)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(rows)):
break
page += 1
return rows
erp_client = ERPClient()
+79
View File
@@ -0,0 +1,79 @@
"""PM — Project Management operational layer. FastAPI entrypoint."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import select
from app.config import settings
from app.db import SessionFactory, engine
from app.dev_accounts import ensure_demo_accounts, ensure_demo_assignments
from app.errors import install_error_handlers
from app.models import Base, PMUser
from app.routers import auth, dashboard, files, projects, qc, tasks, users
from app.security import hash_password
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
async def _ensure_admin() -> None:
"""Seed the local admin user (role: project_manager) from env on first boot."""
async with SessionFactory() as db:
existing = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if existing is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role="project_manager",
)
)
await db.commit()
logging.getLogger("pm.boot").info("seeded admin user %s", settings.ADMIN_USER)
# dev role switcher: demo accounts for the dashboard picker
await ensure_demo_accounts(db)
@asynccontextmanager
async def lifespan(_: FastAPI):
if settings.DEV_ROLE_SWITCH:
logging.getLogger("pm.boot").warning(
"PM_DEV_ROLE_SWITCH is ON — /auth/dev-login issues tokens without a password. "
"Set PM_DEV_ROLE_SWITCH=false for production."
)
async with engine.begin() as conn:
from app.schema_sync import drop_legacy, ensure_schema
await drop_legacy(conn)
await conn.run_sync(Base.metadata.create_all)
await ensure_schema(conn)
await _ensure_admin()
asyncio.create_task(_init_demo_assignments())
yield
async def _init_demo_assignments() -> None:
await asyncio.sleep(1)
try:
async with SessionFactory() as db:
await ensure_demo_assignments(db)
except Exception: # noqa: BLE001
logging.getLogger("pm.boot").exception("demo assignment failed")
app = FastAPI(title="PM — Construction Project Management Platform", version="1.0.0", lifespan=lifespan)
API_V1_PREFIX = "/api/v1"
for module in (auth, users, dashboard, projects, tasks, qc, files):
app.include_router(module.router, prefix=API_V1_PREFIX)
@app.get("/health")
async def health():
return {"status": "ok", "service": "pm-api"}
install_error_handlers(app)
+446
View File
@@ -0,0 +1,446 @@
"""PM data model — Native Construction Project Management Platform.
- Project and Task entities are owned directly by PM with hierarchical WBS,
CPM scheduling, dependencies, and milestones.
- QC domain (templates, definitions, inspections, results, defects) is PM-owned.
- Material requirements and reservations integrate directly with ERP stock.
"""
import enum
import uuid
from datetime import date, datetime
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
def _uuid() -> uuid.UUID:
return uuid.uuid4()
# --- Native PM Platform Models -----------------------------------------------
class ProjectStatus(str, enum.Enum):
PLANNED = "PLANNED"
ACTIVE = "ACTIVE"
ON_HOLD = "ON_HOLD"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskStatus(str, enum.Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
BLOCKED = "BLOCKED"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class TaskRelationType(str, enum.Enum):
FS = "FS" # Finish-to-Start (پایان به شروع)
SS = "SS" # Start-to-Start (شروع به شروع)
FF = "FF" # Finish-to-Finish (پایان به پایان)
SF = "SF" # Start-to-Finish (شروع به پایان)
class Project(Base):
"""Native Construction Project entity owned directly by PM."""
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255), index=True)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(String(255), default="")
project_manager: Mapped[str] = mapped_column(String(255), default="")
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
default=ProjectStatus.ACTIVE.value,
server_default=ProjectStatus.ACTIVE.value,
index=True,
)
progress: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["ProjectMember"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
tasks: Mapped[list["Task"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
relations: Mapped[list["TaskRelation"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
class ProjectMember(Base):
"""Team members assigned to a project with designated roles."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(32), default="site_engineer")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="members")
user: Mapped["PMUser"] = relationship(lazy="selectin")
class Task(Base):
"""Hierarchical Construction Task / Activity entity owned directly by PM.
Supports arbitrary depth: Project -> Phase -> Activity -> Subtask through self-referencing `parent_id`.
"""
__tablename__ = "tasks"
__table_args__ = (
Index("ix_tasks_project", "project_id"),
Index("ix_tasks_parent", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(
String(32),
default=TaskStatus.TODO.value,
server_default=TaskStatus.TODO.value,
index=True,
)
priority: Mapped[str] = mapped_column(
String(32),
default=TaskPriority.MEDIUM.value,
server_default=TaskPriority.MEDIUM.value,
)
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="SET NULL"), nullable=True
)
assignee_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
duration: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
qc_status: Mapped[str] = mapped_column(
String(32),
default="none",
server_default="none",
comment="none|pre_qc_pending|pre_qc_failed|ready|exec_qc_pending|in_progress|qc_failed_rework|final_passed",
)
human_resources: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
materials: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
project: Mapped["Project"] = relationship(back_populates="tasks", lazy="selectin")
parent: Mapped["Task | None"] = relationship(
back_populates="children", remote_side=[id], lazy="selectin"
)
children: Mapped[list["Task"]] = relationship(
back_populates="parent", cascade="all, delete-orphan", lazy="selectin"
)
assignee: Mapped["PMUser | None"] = relationship(lazy="selectin")
outgoing_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.from_task_id]",
back_populates="from_task",
cascade="all, delete-orphan",
)
incoming_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.to_task_id]",
back_populates="to_task",
cascade="all, delete-orphan",
)
progress_logs: Mapped[list["TaskProgressLog"]] = relationship(
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
)
class TaskProgressLog(Base):
"""ثبت گزارش پیشرفت مقطعی/روزانه وظیفه به همراه مصرف واقعی نیروی انسانی و مصالح."""
__tablename__ = "task_progress_logs"
__table_args__ = (
Index("ix_task_progress_logs_task", "task_id"),
Index("ix_task_progress_logs_created", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
progress_before: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
progress_after: Mapped[int] = mapped_column(Integer)
human_resources_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
materials_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
description: Mapped[str] = mapped_column(Text, default="", server_default="")
logged_by: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
task: Mapped["Task"] = relationship(back_populates="progress_logs")
class TaskRelation(Base):
"""Dependency / precedence between two tasks in a project (FS, SS, FF, SF + lag)."""
__tablename__ = "task_relations"
__table_args__ = (
UniqueConstraint("from_task_id", "to_task_id", name="uq_task_relations_pair"),
Index("ix_task_relations_project", "project_id"),
Index("ix_task_relations_from", "from_task_id"),
Index("ix_task_relations_to", "to_task_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
from_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
to_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
relation_type: Mapped[str] = mapped_column(
String(8), default=TaskRelationType.FS.value, server_default=TaskRelationType.FS.value
)
lag_days: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="relations")
from_task: Mapped["Task"] = relationship(
foreign_keys=[from_task_id], back_populates="outgoing_relations"
)
to_task: Mapped["Task"] = relationship(
foreign_keys=[to_task_id], back_populates="incoming_relations"
)
# --- PM-owned QC domain ------------------------------------------------------
class QCQuestionPhase(str, enum.Enum):
"""مرحله‌ای که یک سوال QC به آن تعلق دارد.
pre = پیش از اجرا (آمادگی شروع کار)
during = هنگام اجرا
after = پس از اجرا
"""
pre = "pre"
during = "during"
after = "after"
class QCAnswer(str, enum.Enum):
pass_ = "pass"
fail = "fail"
na = "na"
class QCTemplate(Base):
__tablename__ = "qc_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255))
activity_type: Mapped[str] = mapped_column(String(64), default="", index=True)
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
questions: Mapped[list["QCQuestion"]] = relationship(
back_populates="template", cascade="all, delete-orphan", order_by="QCQuestion.sort_order"
)
class QCQuestion(Base):
__tablename__ = "qc_questions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("qc_templates.id", ondelete="CASCADE"),
index=True,
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
template: Mapped[QCTemplate] = relationship(back_populates="questions")
class TaskQCDefinition(Base):
__tablename__ = "task_qc_definitions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255), default="")
# provenance only — deleting/editing the template never affects this copy
source_template_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
questions: Mapped[list["TaskQCQuestion"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="TaskQCQuestion.sort_order",
)
class TaskQCQuestion(Base):
__tablename__ = "task_qc_questions"
__table_args__ = (Index("ix_task_qc_q_def", "definition_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
definition_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_definitions.id", ondelete="CASCADE")
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
definition: Mapped[TaskQCDefinition] = relationship(back_populates="questions")
results: Mapped[list["TaskQCResult"]] = relationship(
back_populates="question", cascade="all, delete-orphan", order_by="TaskQCResult.evaluated_at"
)
class TaskQCResult(Base):
"""Evaluation of ONE task-specific question — append-only history.
The current answer is the latest row for the question; keeping history is
what makes rework → re-inspection → pass traceable.
"""
__tablename__ = "task_qc_results"
__table_args__ = (Index("ix_task_qc_res_q", "question_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
question_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="CASCADE")
)
answer: Mapped[QCAnswer] = mapped_column(Enum(QCAnswer, name="qc_answer"))
comment: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
evaluated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
evaluated_by: Mapped[str] = mapped_column(String(255), default="")
question: Mapped[TaskQCQuestion] = relationship(back_populates="results")
class Defect(Base):
"""عیب ثبت‌شده روی یک تسک (اختیاری متصل به سؤال QC مردود)."""
__tablename__ = "defects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
question_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
severity: Mapped[str] = mapped_column(String(16), default="medium", server_default="medium")
status: Mapped[str] = mapped_column(String(16), default="open", server_default="open")
corrective_action: Mapped[str] = mapped_column(Text, default="")
resolution_notes: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Auth & access control ------------------------------------------------------
class PMUser(Base):
__tablename__ = "pm_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(255), default="")
# Values live in app/authz.py (Role); stored as text so new roles need no
# Postgres enum migration.
role: Mapped[str] = mapped_column(
String(32),
default="project_manager",
server_default="project_manager",
comment="project_manager|project_supervisor",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectAssignment(Base):
"""Projects a scoped role may access (e.g. a supervisor's assigned projects).
Unscoped roles (project_manager) see every project and need no rows here;
scoped roles are limited to exactly the rows present for them. Source of
truth for the scoping done in app/authz.py.
"""
__tablename__ = "project_assignments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+276
View File
@@ -0,0 +1,276 @@
"""QC domain service — the single source of truth for the QC business rules.
Concepts (kept strictly separate):
* QCTemplate / QCQuestion → global, reusable definition (a starting point)
* TaskQCDefinition/…Question → task-specific COPY the user customizes freely
* TaskQCResult → the evaluation (append-only history) done during execution
Rules enforced here (and reused by routers/tasks.py + the dashboard):
* A task cannot start (progress > 0) while required PRE-EXECUTION QC is unresolved/failed.
* A task cannot reach 100% while ANY required QC question (pre/during/after) is unresolved.
* `na` ("مطرح نیست") counts as satisfied for gating purposes.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
QCAnswer,
QCQuestionPhase,
Task,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
)
PRE = QCQuestionPhase.pre.value
SATISFIED = {QCAnswer.pass_, QCAnswer.na}
# --- status vocabulary --------------------------------------------------------
QC_STATUS_NONE = "none"
QC_STATUS_PRE_PENDING = "pre_qc_pending"
QC_STATUS_PRE_FAILED = "pre_qc_failed"
QC_STATUS_READY = "ready"
QC_STATUS_EXEC_PENDING = "exec_qc_pending"
QC_STATUS_IN_PROGRESS = "in_progress"
QC_STATUS_REWORK = "qc_failed_rework"
QC_STATUS_PASSED = "final_passed"
@dataclass
class PhaseSummary:
phase: str
total: int = 0
passed: int = 0
failed: int = 0
pending: int = 0
na: int = 0
required_total: int = 0
required_unresolved: int = 0
@property
def status(self) -> str:
if self.total == 0:
return "none"
if self.failed:
return "failed"
if self.pending:
return "pending"
return "passed"
def as_dict(self) -> dict:
return {
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"pending": self.pending,
"na": self.na,
"required_total": self.required_total,
"required_unresolved": self.required_unresolved,
"status": self.status,
}
@dataclass
class QCEvaluation:
"""Latest answer of one task QC question."""
question: TaskQCQuestion
latest: TaskQCResult | None
@property
def answer(self) -> str | None:
return self.latest.answer.value if self.latest else None
@property
def satisfied(self) -> bool:
return self.latest is not None and self.latest.answer in SATISFIED
@dataclass
class TaskQCState:
items: list[QCEvaluation] = field(default_factory=list)
@property
def pre(self) -> PhaseSummary:
return _summarize([i for i in self.items if i.question.phase == QCQuestionPhase.pre], "pre")
@property
def exec_(self) -> PhaseSummary:
return _summarize([i for i in self.items if i.question.phase != QCQuestionPhase.pre], "exec")
@property
def all_(self) -> PhaseSummary:
return _summarize(self.items, "all")
@property
def can_start(self) -> bool:
return self.pre.required_unresolved == 0 and self.pre.failed == 0
@property
def can_complete(self) -> bool:
return self.all_.required_unresolved == 0 and self.all_.failed == 0
def block_reasons(self, requested_percentage: int | None = None) -> list[str]:
reasons: list[str] = []
if requested_percentage is not None and requested_percentage > 0 and not self.can_start:
if self.pre.failed:
reasons.append("کنترل کیفیت پیش از اجرا مردود شده است (ایرادات را برطرف کنید)")
elif self.pre.required_unresolved:
reasons.append(
f"{self.pre.required_unresolved} سؤال کنترل کیفیت پیش از اجرا پاسخ داده نشده است"
)
if requested_percentage == 100 and not self.can_complete:
if self.all_.failed:
reasons.append("تسک دارای کنترل کیفیت مردود است و قابل بستن نیست")
elif self.all_.required_unresolved:
reasons.append(
f"{self.all_.required_unresolved} سؤال کنترل کیفیت الزامی هنوز ارزیابی نشده‌اند"
)
return reasons
def status(self, progress: int) -> str:
if not self.items:
return QC_STATUS_NONE
if self.pre.failed:
return QC_STATUS_PRE_FAILED
if self.pre.total > 0 and not self.can_start:
return QC_STATUS_PRE_PENDING
if progress == 0:
return QC_STATUS_READY
if self.exec_.failed:
return QC_STATUS_REWORK
if progress >= 100:
return QC_STATUS_PASSED if self.can_complete else QC_STATUS_EXEC_PENDING
return QC_STATUS_IN_PROGRESS
def _summarize(items: list[QCEvaluation], phase_name: str) -> PhaseSummary:
summary = PhaseSummary(phase=phase_name, total=len(items))
for item in items:
# تمام سؤالات QC نیازمند تعیین تکلیف هستند (قبول، مردود یا مطرح نیست)
summary.required_total += 1
if not item.satisfied:
summary.required_unresolved += 1
ans = item.answer
if ans == QCAnswer.pass_.value:
summary.passed += 1
elif ans == QCAnswer.fail.value:
summary.failed += 1
elif ans is None:
summary.pending += 1
else:
summary.na += 1
return summary
async def load_definitions(db: AsyncSession, task_id: Any) -> list[TaskQCDefinition]:
"""Definitions with questions + their full result history eagerly loaded."""
try:
u = uuid.UUID(str(task_id))
except (ValueError, AttributeError):
return []
stmt = (
select(TaskQCDefinition)
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
.where(TaskQCDefinition.task_id == u)
.order_by(TaskQCDefinition.created_at)
)
rows = (await db.execute(stmt)).scalars().all()
return list(rows)
def _latest(results: list[TaskQCResult]) -> TaskQCResult | None:
if not results:
return None
return max(results, key=lambda r: (r.evaluated_at, r.id))
def build_state(definitions: list[TaskQCDefinition]) -> TaskQCState:
evaluations: list[QCEvaluation] = []
for d in definitions:
for q in d.questions:
evaluations.append(QCEvaluation(question=q, latest=_latest(q.results)))
return TaskQCState(items=evaluations)
async def task_qc_state(db: AsyncSession, task_id: Any) -> TaskQCState:
return build_state(await load_definitions(db, task_id))
def question_dict(evaluation: QCEvaluation) -> dict:
question = evaluation.question
latest = evaluation.latest
return {
"id": str(question.id),
"definition_id": str(question.definition_id),
"text": question.text,
"phase": question.phase.value,
"sort_order": question.sort_order,
"required": question.required,
"result": (
{
"answer": latest.answer.value,
"comment": latest.comment,
"photo_url": f"/api/v1/files/{latest.photo_path}" if latest.photo_path else None,
"photo_id": latest.photo_path,
"evaluated_at": latest.evaluated_at.isoformat() if latest.evaluated_at else None,
"evaluated_by": latest.evaluated_by,
}
if latest
else None
),
"history_count": len(question.results),
}
def definition_dict(definition: TaskQCDefinition, state: TaskQCState) -> dict:
mine = [i for i in state.items if i.question.definition_id == definition.id]
summary = _summarize(mine, "definition")
return {
"id": str(definition.id),
"task_id": str(definition.task_id),
"name": definition.name,
"source_template_id": str(definition.source_template_id) if definition.source_template_id else None,
"created_at": definition.created_at.isoformat() if definition.created_at else None,
"questions": [question_dict(i) for i in mine],
"summary": summary.as_dict(),
}
def state_dict(state: TaskQCState, progress: int, requested_percentage: int | None = None) -> dict:
return {
"pre": state.pre.as_dict(),
"exec": state.exec_.as_dict(),
"all": state.all_.as_dict(),
"status": state.status(progress),
"can_start": state.can_start,
"can_complete": state.can_complete,
"block_reasons": state.block_reasons(requested_percentage),
}
async def recompute_task_qc_status(db: AsyncSession, task_id: Any) -> str | None:
"""Store the derived QC status on the task (dashboard/analytics read it)."""
db.expire_all()
state = await task_qc_state(db, task_id)
try:
native_id = uuid.UUID(str(task_id))
except (ValueError, AttributeError):
return None
task = await db.scalar(select(Task).where(Task.id == native_id))
if task is None:
return None
task.qc_status = state.status(task.progress or 0)
return task.qc_status
+1
View File
@@ -0,0 +1 @@
"""PM API routers."""
+98
View File
@@ -0,0 +1,98 @@
"""Local auth + role/scope introspection.
`POST /auth/login` is the real entry point; `GET /auth/me` re-reads the caller's
role/permissions/scope (used after a role or assignment change, and by the
frontend to self-heal a session stored before RBAC existed). The `/auth/dev-*`
endpoints implement the dashboard role switcher and only exist while
`PM_DEV_ROLE_SWITCH` is enabled.
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import build_context, dev_login_usernames, dev_switch_enabled
from app.db import get_db
from app.dev_accounts import demo_account, ensure_demo_assignments
from app.errors import AuthError, NotFoundError
from app.models import PMUser, ProjectAssignment
from app.security import authenticate, create_token, get_current_user
from app.serializers import user_payload
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginIn(BaseModel):
username: str
password: str
@router.post("/login")
async def login(payload: LoginIn, db: AsyncSession = Depends(get_db)):
user = await authenticate(db, payload.username, payload.password)
if user is None:
raise AuthError("نام کاربری یا رمز عبور اشتباه است")
ctx = await build_context(db, user)
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
@router.get("/me")
async def me(user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return {"data": user_payload(await build_context(db, user))}
# --- development role switcher -------------------------------------------------
def _require_dev_switch() -> None:
if not dev_switch_enabled():
raise NotFoundError("تغییر نقش فقط در فاز توسعه فعال است")
@router.get("/dev-roles")
async def dev_roles(db: AsyncSession = Depends(get_db)):
"""Demo identities offered by the dashboard role picker (dev only)."""
_require_dev_switch()
# projects only exist after the first pull — self-heal the demo scope here
await ensure_demo_assignments(db)
data = []
for username in dev_login_usernames():
account = demo_account(username)
if account is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
projects = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
data.append(
{
"username": user.username,
"display_name": user.display_name,
"role": account.role.value,
"project_count": projects or 0,
"all_projects": account.share is None,
}
)
return {"data": data}
class DevLoginIn(BaseModel):
username: str
@router.post("/dev-login")
async def dev_login(payload: DevLoginIn, db: AsyncSession = Depends(get_db)):
"""Issue a token for a demo account — dev role switching without a password.
Restricted to the configured demo usernames, never arbitrary users.
"""
_require_dev_switch()
if payload.username not in dev_login_usernames():
raise AuthError("این حساب در فهرست حساب‌های توسعه نیست")
user = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if user is None:
raise AuthError("حساب نمایشی ساخته نشده است؛ سرور را دوباره راه‌اندازی کنید")
ctx = await build_context(db, user)
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
+113
View File
@@ -0,0 +1,113 @@
"""Today dashboard.
Both roles land here, but they need different things from it: the project
manager gets a management overview (every project, with its counters), the
supervisor gets an operational view of the activities in the projects assigned
to them. The data is the same query either way — only scoped by role and
presented differently (see frontend TodayPage).
"""
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.models import Project, Task
from app.serializers import _iso, native_task_dict
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
view_dashboard = require_permission(Perm.dashboard_view)
async def _native_project_overview(db: AsyncSession, ctx: AuthContext) -> list[dict]:
"""کاشی‌های مدیریتی پروژه‌های بومی برای داشبورد مدیر پروژه."""
proj_stmt = ctx.scope_native(select(Project).order_by(Project.name), Project.id)
projects = (await db.execute(proj_stmt)).scalars().all()
tasks = (await db.execute(ctx.scope_native(select(Task), Task.project_id))).scalars().all()
today_date = date.today()
by_project: dict = {}
for t in tasks:
by_project.setdefault(t.project_id, []).append(t)
rows: list[dict] = []
for p in projects:
mine = by_project.get(p.id, [])
open_tasks = [t for t in mine if t.status not in ("COMPLETED", "CANCELLED")]
stats = {
"total": len(mine),
"active": len(open_tasks),
"awaiting_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")),
"failed_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_failed", "qc_failed_rework")),
"overdue": sum(1 for t in open_tasks if t.planned_end and t.planned_end < today_date),
}
rows.append(
{
"id": str(p.id),
"name": p.name,
"identifier": str(p.id),
"active": p.status == "ACTIVE",
"stats": stats,
}
)
return rows
@router.get("/today")
async def today(ctx: AuthContext = Depends(view_dashboard), db: AsyncSession = Depends(get_db)):
native_stmt = ctx.scope_native(
select(Task).options(selectinload(Task.project)), Task.project_id
)
native_tasks = (await db.execute(native_stmt)).scalars().all()
today_date = date.today()
week_end = today_date + timedelta(days=7)
def is_task_open(t: Task) -> bool:
return t.status != "COMPLETED" and t.status != "CANCELLED"
overdue = [t for t in native_tasks if is_task_open(t) and t.planned_end and t.planned_end < today_date]
due_today = [t for t in native_tasks if is_task_open(t) and t.planned_end == today_date]
upcoming = [
t for t in native_tasks if is_task_open(t) and t.planned_end and today_date < t.planned_end <= week_end
]
awaiting_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")]
failed_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_failed", "qc_failed_rework")]
blocked = [t for t in native_tasks if is_task_open(t) and t.status == "BLOCKED"]
def _native_row(t: Task) -> dict:
p_name = t.project.name if t.project else ""
d = native_task_dict(t, project_name=p_name)
d["subject"] = t.title
d["percentage_done"] = t.progress
d["start_date"] = _iso(t.planned_start)
d["due_date"] = _iso(t.planned_end)
d["is_closed"] = t.status == "COMPLETED"
return d
problems = len(overdue) + len(failed_qc)
warnings = len(awaiting_qc) + len(blocked)
ok = max(len(native_tasks) - problems - warnings, 0)
data = {
"role": ctx.role.value,
"role_label": ctx.role_label,
"scope": ctx.as_dict()["scope"],
"summary": {"problems": problems, "warnings": warnings, "ok": ok},
"sections": {
"overdue": [_native_row(t) for t in sorted(overdue, key=lambda x: x.planned_end or date.max)],
"due_today": [_native_row(t) for t in due_today],
"awaiting_qc": [_native_row(t) for t in awaiting_qc],
"failed_qc": [_native_row(t) for t in failed_qc],
"blocked": [_native_row(t) for t in blocked],
"upcoming": [_native_row(t) for t in sorted(upcoming, key=lambda x: x.planned_end or date.max)[:10]],
},
}
# نمای مدیریتی پروژه‌ها فقط برای نقش بدون محدودیت (مدیر پروژه)
if ctx.all_projects:
data["projects"] = await _native_project_overview(db, ctx)
return {"data": data}
+54
View File
@@ -0,0 +1,54 @@
"""Defect photos — upload to PM volume, serve (LAN-internal, unauthenticated)."""
import uuid
from fastapi import APIRouter, Depends, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import AuthContext, Perm, require_permission
from app.config import settings
from app.db import get_db
from app.errors import ValidationError
router = APIRouter(prefix="/files", tags=["files"])
ALLOWED = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_SIZE = 10 * 1024 * 1024
# photos are attached to QC defects, so both roles may upload
upload_file = require_permission(Perm.files_upload)
@router.post("")
async def upload(
file: UploadFile,
_: AuthContext = Depends(upload_file),
db: AsyncSession = Depends(get_db),
):
_ = db
ext = ALLOWED.get(file.content_type or "")
if ext is None:
raise ValidationError("فقط تصویر jpg/png/webp پذیرفته میشود", field="file")
data = await file.read()
if len(data) > MAX_SIZE:
raise ValidationError("حجم فایل بیش از حد مجاز است (۱۰ مگابایت)", field="file")
settings.FILES_DIR.mkdir(parents=True, exist_ok=True)
file_id = uuid.uuid4().hex
path = settings.FILES_DIR / f"{file_id}{ext}"
path.write_bytes(data)
return {"data": {"id": file_id, "url": f"/api/v1/files/{file_id}", "filename": file.filename}}
@router.get("/{file_id}")
async def serve(file_id: str, db: AsyncSession = Depends(get_db)):
_ = db
if not file_id.isalnum():
raise ValidationError("شناسه فایل نامعتبر است")
for ext in ALLOWED.values():
path = settings.FILES_DIR / f"{file_id}{ext}"
if path.exists():
media = next(k for k, v in ALLOWED.items() if v == ext)
return FileResponse(path, media_type=media)
from app.errors import NotFoundError
raise NotFoundError("فایل یافت نشد")
+601
View File
@@ -0,0 +1,601 @@
"""Projects (Native PM Construction Platform Projects).
Supports:
- Project list with real-time statistics and scoping.
- Creation, editing, archiving of native projects.
- Project team members management.
- Unique project code and UUID resolution.
"""
from __future__ import annotations
import uuid
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Project,
ProjectMember,
ProjectStatus,
Task,
TaskRelation,
TaskRelationType,
)
from app.scheduling_service import (
calculate_schedule,
check_for_cycle,
rollup_parent_tasks,
sync_project_lifecycle_dates,
)
from app.serializers import _iso, project_dict, project_member_dict, task_relation_dict
from app.utils.date_service import date_service
from app.utils.jalali import to_jalali_str
router = APIRouter(prefix="/projects", tags=["projects"])
ACTIVE_QC = ("pre_qc_pending", "exec_qc_pending", "ready")
FAILED_QC = ("pre_qc_failed", "qc_failed_rework")
view_projects = require_permission(Perm.projects_view)
manage_projects = require_permission(Perm.projects_create)
class ProjectCreateSchema(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = ""
location: str = ""
project_manager: str = ""
planned_start: str | None = None
planned_end: str | None = None
status: str = ProjectStatus.ACTIVE.value
class ProjectUpdateSchema(BaseModel):
name: str | None = None
description: str | None = None
location: str | None = None
project_manager: str | None = None
planned_start: str | None = None
planned_end: str | None = None
actual_start: str | None = None
actual_end: str | None = None
status: str | None = None
progress: float | None = None
class ProjectMemberCreateSchema(BaseModel):
user_id: str
role: str = "site_engineer"
class ErpMappingSchema(BaseModel):
erp_project_id: str | None = None
class TaskRelationCreateSchema(BaseModel):
from_task_id: str
to_task_id: str
relation_type: str = "FS"
lag_days: int = 0
class TaskRelationUpdateSchema(BaseModel):
"""Edit an existing dependency. The endpoints (from/to tasks) are immutable —
changing them is a different relation, so it must be deleted and recreated."""
relation_type: str | None = None
lag_days: int | None = None
async def _resolve_project(db: AsyncSession, ctx: AuthContext, project_id_or_op: str | int) -> Project:
"""Find a project by UUID and enforce scope."""
query = select(Project).options(
selectinload(Project.members).selectinload(ProjectMember.user)
)
try:
val_uuid = uuid.UUID(str(project_id_or_op))
stmt = query.where(Project.id == val_uuid)
except ValueError:
raise NotFoundError("شناسه پروژه نامعتبر است")
project = (await db.execute(stmt)).scalar_one_or_none()
if not project:
raise NotFoundError("پروژه مورد نظر یافت نشد")
ctx.ensure_native_project(project.id)
return project
async def native_project_stats(db: AsyncSession, project_id: uuid.UUID) -> dict:
"""Task counters of one native project."""
today = date_service.today()
rows = (
await db.execute(
select(Task.qc_status, Task.planned_end, Task.status, func.count())
.where(Task.project_id == project_id)
.group_by(Task.qc_status, Task.planned_end, Task.status)
)
).all()
total = sum(r[3] for r in rows)
completed = sum(r[3] for r in rows if r[2] == "COMPLETED")
in_progress = sum(r[3] for r in rows if r[2] == "IN_PROGRESS")
blocked = sum(r[3] for r in rows if r[2] == "BLOCKED")
awaiting = sum(r[3] for r in rows if r[0] in ACTIVE_QC)
failed = sum(r[3] for r in rows if r[0] in FAILED_QC)
overdue = sum(r[3] for r in rows if r[2] != "COMPLETED" and r[1] and r[1] < today)
return {
"total": total,
"completed": completed,
"in_progress": in_progress,
"blocked": blocked,
"awaiting_qc": awaiting,
"failed_qc": failed,
"overdue": overdue,
}
@router.get("")
async def list_projects(
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""List all projects visible to the user, with stats and team counts."""
stmt = select(Project).options(selectinload(Project.members)).order_by(Project.created_at.desc())
# Apply project scoping for supervisor / scoped roles (native projects = UUID)
stmt = ctx.scope_native(stmt, Project.id)
projects = (await db.execute(stmt)).scalars().all()
results = []
for p in projects:
stats = await native_project_stats(db, p.id)
results.append(project_dict(p, member_count=len(p.members), task_stats=stats))
return {"data": results}
@router.post("")
async def create_project(
payload: ProjectCreateSchema,
ctx: AuthContext = Depends(manage_projects),
db: AsyncSession = Depends(get_db),
):
"""Create a new native project."""
p_start = date_service.parse(payload.planned_start) if payload.planned_start else None
p_end = date_service.parse(payload.planned_end) if payload.planned_end else None
if p_start and p_end and p_end < p_start:
raise ValidationError("تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", field="planned_end")
project = Project(
name=payload.name.strip(),
description=payload.description.strip(),
location=payload.location.strip() if payload.location else "",
project_manager=payload.project_manager.strip() if payload.project_manager else ctx.user.display_name,
planned_start=p_start,
planned_end=p_end,
status=payload.status.upper(),
)
db.add(project)
await db.flush()
# Automatically add the creator as a project manager member
creator_member = ProjectMember(
project_id=project.id,
user_id=ctx.user.id,
role="project_manager",
)
db.add(creator_member)
await db.commit()
await db.refresh(project)
stats = {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0}
res = project_dict(project, member_count=1, task_stats=stats)
res["identifier"] = str(project.id)
return {"data": res}
@router.get("/{id}")
async def get_project(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""Get project details, member list, and overall stats."""
project = await _resolve_project(db, ctx, id)
stats = await native_project_stats(db, project.id)
data = project_dict(project, member_count=len(project.members), task_stats=stats)
data["identifier"] = str(project.id)
data["members"] = [project_member_dict(m) for m in project.members]
return {"data": data}
@router.patch("/{id}")
async def update_project(
id: str,
payload: ProjectUpdateSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Update project details, status, or dates."""
project = await _resolve_project(db, ctx, id)
if payload.name is not None:
project.name = payload.name.strip()
if payload.description is not None:
project.description = payload.description.strip()
if payload.location is not None:
project.location = payload.location.strip()
if payload.project_manager is not None:
project.project_manager = payload.project_manager.strip()
if payload.status is not None:
project.status = payload.status.upper()
if payload.progress is not None:
project.progress = max(0.0, min(100.0, payload.progress))
if payload.planned_start is not None:
project.planned_start = date_service.parse(payload.planned_start)
if payload.planned_end is not None:
project.planned_end = date_service.parse(payload.planned_end)
if payload.actual_start is not None:
project.actual_start = date_service.parse(payload.actual_start)
if payload.actual_end is not None:
project.actual_end = date_service.parse(payload.actual_end)
if project.planned_start and project.planned_end and project.planned_end < project.planned_start:
raise ValidationError("تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", field="planned_end")
await db.commit()
await db.refresh(project)
stats = await native_project_stats(db, project.id)
return {"data": project_dict(project, member_count=len(project.members), task_stats=stats)}
@router.post("/{id}/members")
async def add_project_member(
id: str,
payload: ProjectMemberCreateSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Add a team member to the project."""
project = await _resolve_project(db, ctx, id)
target_user_id = uuid.UUID(payload.user_id)
# Check if already a member
existing = await db.scalar(
select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
)
)
if existing:
existing.role = payload.role
else:
member = ProjectMember(
project_id=project.id,
user_id=target_user_id,
role=payload.role,
)
db.add(member)
await db.commit()
# Return updated members list
await db.refresh(project)
project = await _resolve_project(db, ctx, project.id)
return {"data": [project_member_dict(m) for m in project.members]}
@router.delete("/{id}/members/{user_id}")
async def remove_project_member(
id: str,
user_id: str,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Remove a team member from the project."""
project = await _resolve_project(db, ctx, id)
target_user_id = uuid.UUID(user_id)
member = await db.scalar(
select(ProjectMember).where(
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
)
)
if member:
await db.delete(member)
await db.commit()
project = await _resolve_project(db, ctx, project.id)
return {"data": [project_member_dict(m) for m in project.members]}
@router.patch("/{id}/erp-mapping")
async def map_erp_project(
id: str,
payload: ErpMappingSchema,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
project = await _resolve_project(db, ctx, id)
return {"data": {"id": str(project.id)}}
# --- Relations & Dependencies ---
@router.get("/{id}/relations")
async def list_project_relations(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""List all task dependencies in a project."""
project = await _resolve_project(db, ctx, id)
stmt = (
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.project_id == project.id)
)
relations = (await db.execute(stmt)).scalars().all()
return {"data": [task_relation_dict(r) for r in relations]}
@router.post("/{id}/relations")
async def create_task_relation(
id: str,
payload: TaskRelationCreateSchema,
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
db: AsyncSession = Depends(get_db),
):
"""Create a new task dependency (FS, SS, FF, SF + lag). Enforces cycle check."""
project = await _resolve_project(db, ctx, id)
from_id = uuid.UUID(payload.from_task_id)
to_id = uuid.UUID(payload.to_task_id)
if from_id == to_id:
raise ValidationError("یک وظیفه نمی‌تواند پیش‌نیاز خودش باشد")
# Verify tasks belong to this project
from_task = await db.get(Task, from_id)
to_task = await db.get(Task, to_id)
if not from_task or from_task.project_id != project.id:
raise ValidationError("وظیفه پیش‌نیاز در این پروژه یافت نشد")
if not to_task or to_task.project_id != project.id:
raise ValidationError("وظیفه وابسته در این پروژه یافت نشد")
# Load existing relations to verify DAG
existing_rels = (
await db.execute(select(TaskRelation.from_task_id, TaskRelation.to_task_id).where(TaskRelation.project_id == project.id))
).all()
existing_pairs = [(r[0], r[1]) for r in existing_rels]
# Check for duplicate
if (from_id, to_id) in existing_pairs:
raise ValidationError("این وابستگی قبلاً ثبت شده است")
# Cycle check
if check_for_cycle(existing_pairs, from_id, to_id):
raise ValidationError("این وابستگی باعث ایجاد چرخه (حلقه تکرار) در ساختار زمان‌بندی می‌شود")
rtype = payload.relation_type.upper()
if rtype not in ("FS", "SS", "FF", "SF"):
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
relation = TaskRelation(
project_id=project.id,
from_task_id=from_id,
to_task_id=to_id,
relation_type=rtype,
lag_days=payload.lag_days,
)
db.add(relation)
await db.commit()
# Recalculate schedule cascade automatically
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
# Update tasks with new early dates if changed
for t in all_tasks:
if t.id in sched_result["tasks_schedule"]:
info = sched_result["tasks_schedule"][t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
await db.commit()
await db.refresh(relation)
return {"data": task_relation_dict(relation, from_task.title, to_task.title)}
@router.patch("/{id}/relations/{relation_id}")
async def update_task_relation(
id: str,
relation_id: str,
payload: TaskRelationUpdateSchema,
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
db: AsyncSession = Depends(get_db),
):
"""Edit a dependency's type and/or lag, then re-run the schedule cascade."""
project = await _resolve_project(db, ctx, id)
rel = await db.scalar(
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.id == uuid.UUID(relation_id), TaskRelation.project_id == project.id)
)
if rel is None:
raise NotFoundError("وابستگی یافت نشد")
if payload.relation_type is not None:
rtype = payload.relation_type.upper()
if rtype not in ("FS", "SS", "FF", "SF"):
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
rel.relation_type = rtype
if payload.lag_days is not None:
rel.lag_days = payload.lag_days
await db.commit()
# Recalculate the schedule so the edit takes effect immediately
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
for t in all_tasks:
if t.id in sched_result["tasks_schedule"]:
info = sched_result["tasks_schedule"][t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
await db.commit()
await db.refresh(rel)
return {"data": task_relation_dict(rel, rel.from_task.title, rel.to_task.title)}
@router.delete("/{id}/relations/{relation_id}")
async def delete_task_relation(
id: str,
relation_id: str,
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
db: AsyncSession = Depends(get_db),
):
"""Delete a task dependency."""
project = await _resolve_project(db, ctx, id)
rel_uuid = uuid.UUID(relation_id)
rel = await db.scalar(
select(TaskRelation).where(TaskRelation.id == rel_uuid, TaskRelation.project_id == project.id)
)
if rel:
await db.delete(rel)
await db.commit()
return {"data": {"deleted": True}}
# --- Interactive Gantt & Topological Scheduling ---
@router.get("/{id}/gantt")
async def get_project_gantt(
id: str,
ctx: AuthContext = Depends(view_projects),
db: AsyncSession = Depends(get_db),
):
"""Return full Gantt chart dataset with CPM calculations, relations, and critical path."""
project = await _resolve_project(db, ctx, id)
# 1. Fetch tasks, relations, milestones
tasks = (
await db.execute(
select(Task)
.options(selectinload(Task.children))
.where(Task.project_id == project.id)
.order_by(Task.planned_start.asc().nulls_last(), Task.created_at.asc())
)
).scalars().all()
relations = (
await db.execute(
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(TaskRelation.project_id == project.id)
)
).scalars().all()
# 2. Run CPM scheduling analysis
sched = calculate_schedule(tasks, relations, project.planned_start)
sched_info = sched["tasks_schedule"]
crit_set = set(sched["critical_path"])
# 3. Format tasks
formatted_tasks = []
for t in tasks:
t_sched = sched_info.get(t.id, {})
start_dt = t_sched.get("planned_start") or t.planned_start
end_dt = t_sched.get("planned_end") or t.planned_end
formatted_tasks.append({
"id": str(t.id),
"title": t.title,
"subject": t.title,
"parent_id": str(t.parent_id) if t.parent_id else None,
"status": t.status,
"priority": t.priority,
"planned_start": _iso(start_dt),
"planned_end": _iso(end_dt),
"planned_start_jalali": to_jalali_str(start_dt),
"planned_end_jalali": to_jalali_str(end_dt),
"duration": t_sched.get("duration") or t.duration or 1,
"progress": t.progress or 0,
"is_critical": t.id in crit_set,
"float_days": t_sched.get("float_days", 0),
"qc_status": t.qc_status or "none",
"assignee_name": t.assignee_name or (t.assignee.display_name if getattr(t, "assignee", None) else None),
})
return {
"data": {
"project": {
"id": str(project.id),
"identifier": str(project.id),
"name": project.name,
"planned_start": _iso(project.planned_start),
"planned_end": _iso(project.planned_end),
"planned_start_jalali": to_jalali_str(project.planned_start),
"planned_end_jalali": to_jalali_str(project.planned_end),
"progress": project.progress,
},
"tasks": formatted_tasks,
"relations": [task_relation_dict(r) for r in relations],
"critical_path": [str(tid) for tid in sched["critical_path"]],
"project_finish": _iso(sched.get("project_finish")),
"project_finish_jalali": to_jalali_str(sched.get("project_finish")),
}
}
@router.post("/{id}/reschedule")
async def reschedule_project(
id: str,
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
db: AsyncSession = Depends(get_db),
):
"""Trigger full topological forward-pass recalculation and persist cascaded dates."""
project = await _resolve_project(db, ctx, id)
tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
sched = calculate_schedule(tasks, relations, project.planned_start)
sched_info = sched["tasks_schedule"]
updated_count = 0
for t in tasks:
if t.id in sched_info:
info = sched_info[t.id]
t.planned_start = info["planned_start"]
t.planned_end = info["planned_end"]
t.duration = info["duration"]
updated_count += 1
# Roll up summary tasks
rollups = rollup_parent_tasks(tasks)
for pid, rdata in rollups.items():
parent_task = await db.get(Task, pid)
if parent_task:
if rdata["planned_start"]:
parent_task.planned_start = rdata["planned_start"]
if rdata["planned_end"]:
parent_task.planned_end = rdata["planned_end"]
if rdata.get("duration"):
parent_task.duration = rdata["duration"]
await sync_project_lifecycle_dates(db, project.id)
await db.commit()
return {"data": {"recalculated": True, "tasks_updated": updated_count}}
+729
View File
@@ -0,0 +1,729 @@
"""QC module.
Two clearly separated halves:
**تعریف (Definition)** — what must eventually be checked
* `QCTemplate` + `QCQuestion` : global, reusable, editable
* `TaskQCDefinition`/`TaskQCQuestion`: a task-specific COPY the user customizes
**اجرا (Execution)** — the actual evaluation during the project
* `TaskQCResult` : append-only answers (pass / fail / na) + comment
* `Defect` : problems raised on a task, with corrective action
Applying a template to a task deep-copies its questions, so customizing a task
never mutates the global template.
Access split (app/authz.py):
* `qc.define` — templates + the per-task QC structure: project manager only
* `qc.execute` — recording answers and defects, on assigned projects
* `qc.view` — reading status/readiness, on assigned projects
"""
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import (
AuthContext,
Perm,
require_permission,
scoped_task,
)
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Defect,
QCAnswer,
QCQuestion,
QCQuestionPhase,
QCTemplate,
Task,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
)
from app.qc_service import (
PRE,
build_state,
definition_dict,
load_definitions,
question_dict,
recompute_task_qc_status,
state_dict,
task_qc_state,
)
from app.serializers import _iso
router = APIRouter(prefix="/qc", tags=["qc"])
define_qc = require_permission(Perm.qc_define)
execute_qc = require_permission(Perm.qc_execute)
view_qc = require_permission(Perm.qc_view)
PHASE_LABELS = {PRE: "پیش از اجرا", "during": "هنگام اجرا", "after": "پس از اجرا"}
def _phase(value: str) -> QCQuestionPhase:
try:
return QCQuestionPhase(value)
except ValueError as exc:
raise ValidationError("مرحله باید pre، during یا after باشد", field="phase") from exc
def _answer(value: str) -> QCAnswer:
try:
return QCAnswer(value)
except ValueError as exc:
raise ValidationError("پاسخ باید pass، fail یا na باشد", field="answer") from exc
@dataclass
class QCTaskRef:
"""A task as the QC module sees it."""
id: uuid.UUID
subject: str
progress: int
status: str
@property
def key(self):
return self.id
async def _qc_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> QCTaskRef:
"""Resolve the task a QC screen is about, enforcing the caller's scope."""
native = await scoped_task(db, ctx, task_ref)
return QCTaskRef(
id=native.id,
subject=native.title or "",
progress=native.progress or 0,
status=native.status or "",
)
async def _ensure_definition_scope(db: AsyncSession, ctx: AuthContext, definition: TaskQCDefinition) -> None:
native = await db.get(Task, definition.task_id)
if native is not None:
ctx.ensure_project(native.project_id)
return
raise NotFoundError("تعریف QC یافت نشد")
async def _definition(db: AsyncSession, definition_id: uuid.UUID) -> TaskQCDefinition:
row = await db.scalar(
select(TaskQCDefinition)
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
.where(TaskQCDefinition.id == definition_id)
)
if row is None:
raise NotFoundError("تعریف QC یافت نشد")
return row
async def _scoped_definition(db: AsyncSession, ctx: AuthContext, definition_id: uuid.UUID) -> TaskQCDefinition:
definition = await _definition(db, definition_id)
await _ensure_definition_scope(db, ctx, definition)
return definition
async def _question(db: AsyncSession, question_id: uuid.UUID) -> TaskQCQuestion:
row = await db.scalar(
select(TaskQCQuestion)
.options(selectinload(TaskQCQuestion.results))
.where(TaskQCQuestion.id == question_id)
)
if row is None:
raise NotFoundError("سؤال QC یافت نشد")
return row
async def _scoped_definition_question(
db: AsyncSession, ctx: AuthContext, question_id: uuid.UUID
) -> tuple[TaskQCQuestion, TaskQCDefinition]:
question = await _question(db, question_id)
definition = await db.get(TaskQCDefinition, question.definition_id)
if definition is None:
raise NotFoundError("تعریف QC یافت نشد")
await _ensure_definition_scope(db, ctx, definition)
return question, definition
# =============================================================================
# Global QC templates
# =============================================================================
def _template_dict(template: QCTemplate) -> dict:
return {
"id": str(template.id),
"name": template.name,
"activity_type": template.activity_type,
"description": template.description,
"questions": [
{
"id": str(q.id),
"phase": q.phase.value,
"text": q.text,
"sort_order": q.sort_order,
"required": q.required,
}
for q in template.questions
],
}
async def _template(db: AsyncSession, template_id: uuid.UUID) -> QCTemplate:
row = await db.scalar(
select(QCTemplate)
.options(selectinload(QCTemplate.questions))
.where(QCTemplate.id == template_id)
)
if row is None:
raise NotFoundError("قالب QC یافت نشد")
return row
@router.get("/templates")
async def list_templates(activity_type: str | None = None, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
stmt = select(QCTemplate).options(selectinload(QCTemplate.questions)).order_by(QCTemplate.name)
if activity_type:
stmt = stmt.where(QCTemplate.activity_type == activity_type)
templates = (await db.execute(stmt)).scalars().all()
return {"data": [_template_dict(t) for t in templates]}
@router.get("/templates/{template_id}")
async def get_template(template_id: uuid.UUID, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
return {"data": _template_dict(await _template(db, template_id))}
class QuestionIn(BaseModel):
phase: str
text: str
required: bool = True
sort_order: int = 0
class TemplateIn(BaseModel):
name: str
activity_type: str = ""
description: str = ""
questions: list[QuestionIn] = []
@field_validator("name")
@classmethod
def _name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام قالب الزامی است")
return v.strip()
@router.post("/templates")
async def create_template(payload: TemplateIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = QCTemplate(
name=payload.name,
activity_type=payload.activity_type,
description=payload.description,
)
db.add(template)
await db.flush()
for index, q in enumerate(payload.questions):
db.add(
QCQuestion(
template_id=template.id,
phase=_phase(q.phase),
text=q.text.strip(),
sort_order=q.sort_order if q.sort_order else index,
required=q.required,
)
)
await db.commit()
return {"data": _template_dict(await _template(db, template.id))}
class TemplatePatch(BaseModel):
name: str | None = None
activity_type: str | None = None
description: str | None = None
@router.patch("/templates/{template_id}")
async def patch_template(
template_id: uuid.UUID, payload: TemplatePatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
if payload.name is not None:
if not payload.name.strip():
raise ValidationError("نام قالب نمی‌تواند خالی باشد", field="name")
template.name = payload.name.strip()
if payload.activity_type is not None:
template.activity_type = payload.activity_type
if payload.description is not None:
template.description = payload.description
await db.commit()
return {"data": _template_dict(template)}
@router.delete("/templates/{template_id}")
async def delete_template(template_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = await _template(db, template_id)
await db.delete(template)
await db.commit()
return {"data": {"deleted": True}}
@router.post("/templates/{template_id}/questions")
async def add_template_question(
template_id: uuid.UUID, payload: QuestionIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
question = QCQuestion(
template_id=template.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
await db.commit()
return {"data": _template_dict(await _template(db, template_id))}
class QuestionPatch(BaseModel):
text: str | None = None
phase: str | None = None
required: bool | None = None
sort_order: int | None = None
@router.patch("/questions/{question_id}")
async def patch_question(
question_id: uuid.UUID, payload: QuestionPatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
if payload.text is not None:
if not payload.text.strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = payload.text.strip()
if payload.phase is not None:
question.phase = _phase(payload.phase)
if payload.required is not None:
question.required = payload.required
if payload.sort_order is not None:
question.sort_order = payload.sort_order
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/questions/{question_id}")
async def delete_question(question_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
await db.delete(question)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Task QC Definitions
# =============================================================================
class DefinitionIn(BaseModel):
name: str = ""
template_id: str | None = None
@router.get("/tasks/{task_ref}")
async def task_qc(
task_ref: str, ctx: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)
):
"""Everything the QC screen needs: definitions + questions + results + readiness."""
task = await _qc_task(db, ctx, task_ref)
definitions = await load_definitions(db, task.key)
state = build_state(definitions)
return {
"data": {
"task": {
"id": str(task.id),
"subject": task.subject,
"percentage_done": task.progress,
"status": task.status,
},
"definitions": [definition_dict(d, state) for d in definitions],
"readiness": state_dict(state, progress=task.progress),
}
}
@router.post("/tasks/{task_ref}/definition")
async def create_definition(
task_ref: str,
payload: DefinitionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
task = await _qc_task(db, ctx, task_ref)
template: QCTemplate | None = None
if payload.template_id:
try:
template_uuid = uuid.UUID(payload.template_id)
except ValueError as exc:
raise ValidationError("شناسه قالب نامعتبر است", field="template_id") from exc
template = await _template(db, template_uuid)
definition = TaskQCDefinition(
task_id=task.id,
name=payload.name.strip() or (template.name if template else "QC تسک"),
source_template_id=template.id if template else None,
)
db.add(definition)
await db.flush()
if template:
for index, question in enumerate(template.questions):
db.add(
TaskQCQuestion(
definition_id=definition.id,
phase=question.phase,
text=question.text,
sort_order=index,
required=question.required,
)
)
await db.commit()
await recompute_task_qc_status(db, task.key)
await db.commit()
return {"data": {"id": str(definition.id)}}
class DefinitionPatch(BaseModel):
name: str
@router.patch("/definitions/{definition_id}")
async def patch_definition(
definition_id: uuid.UUID,
payload: DefinitionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
if not payload.name.strip():
raise ValidationError("نام تعریف الزامی است", field="name")
definition.name = payload.name.strip()
await db.commit()
return {"data": {"id": str(definition.id), "name": definition.name}}
@router.delete("/definitions/{definition_id}")
async def delete_definition(
definition_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
definition = await _scoped_definition(db, ctx, definition_id)
task_key = definition.task_id
await db.delete(definition)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
class TaskQuestionIn(BaseModel):
text: str
phase: str
required: bool = True
sort_order: int = 0
@router.post("/definitions/{definition_id}/questions")
async def add_task_question(
definition_id: uuid.UUID,
payload: TaskQuestionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
question = TaskQCQuestion(
definition_id=definition.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.patch("/task-questions/{question_id}")
async def patch_task_question(
question_id: uuid.UUID,
payload: QuestionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
values = payload.model_dump(exclude_unset=True)
if "text" in values:
if not values["text"].strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = values["text"].strip()
values.pop("text")
if "phase" in values:
question.phase = _phase(values.pop("phase"))
for key, value in values.items():
setattr(question, key, value)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/task-questions/{question_id}")
async def delete_task_question(
question_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
task_key = definition.task_id
await db.delete(question)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Execution / evaluation
# =============================================================================
class ResultIn(BaseModel):
answer: str | None = None
comment: str = ""
photo_id: str | None = None
@router.put("/task-questions/{question_id}/result")
async def evaluate_question(
question_id: uuid.UUID,
payload: ResultIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
# اگر پاسخ خالی یا null باشد، انتخاب قبلی لغو شده و سؤال به حالت بدون پاسخ برمی‌گردد
if not payload.answer or payload.answer.strip().lower() in ("none", "null", "cancel", "clear", "pending"):
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
user = ctx.user
answer = _answer(payload.answer)
existing_latest = await db.scalar(
select(TaskQCResult)
.where(TaskQCResult.question_id == question.id)
.order_by(TaskQCResult.evaluated_at.desc(), TaskQCResult.id.desc())
)
photo_path = payload.photo_id
if photo_path is None and existing_latest and existing_latest.answer == answer:
photo_path = existing_latest.photo_path
elif photo_path == "":
photo_path = None
db.add(
TaskQCResult(
question_id=question.id,
answer=answer,
comment=payload.comment.strip(),
photo_path=photo_path,
evaluated_by=user.display_name or user.username,
)
)
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": answer.value,
"photo_url": f"/api/v1/files/{photo_path}" if photo_path else None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
@router.delete("/task-questions/{question_id}/result")
async def delete_question_result(
question_id: uuid.UUID,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
async def _progress(db: AsyncSession, task_id: uuid.UUID) -> int:
task = await db.scalar(select(Task).where(Task.id == task_id))
return (task.progress or 0) if task else 0
# =============================================================================
# Defects
# =============================================================================
def _defect_dict(defect: Defect) -> dict:
return {
"id": str(defect.id),
"task_id": str(defect.task_id),
"question_id": str(defect.question_id) if defect.question_id else None,
"title": defect.title,
"description": defect.description,
"severity": defect.severity,
"status": defect.status,
"corrective_action": defect.corrective_action,
"resolution_notes": defect.resolution_notes,
"photo_url": f"/api/v1/files/{defect.photo_path}" if defect.photo_path else None,
"created_at": _iso(defect.created_at),
"resolved_at": _iso(defect.resolved_at),
}
class DefectIn(BaseModel):
task_ref: str
question_id: str | None = None
title: str
description: str = ""
severity: str = "medium"
corrective_action: str = ""
photo_id: str | None = None
@field_validator("title")
@classmethod
def _title(cls, v: str) -> str:
if not v.strip():
raise ValueError("عنوان عیب الزامی است")
return v.strip()
@router.post("/defects")
async def create_defect(
payload: DefectIn, ctx: AuthContext = Depends(execute_qc), db: AsyncSession = Depends(get_db)
):
task = await _qc_task(db, ctx, payload.task_ref)
question_id = None
if payload.question_id:
try:
question_id = uuid.UUID(payload.question_id)
except ValueError as exc:
raise ValidationError("شناسه سؤال نامعتبر است", field="question_id") from exc
defect = Defect(
task_id=task.id,
question_id=question_id,
title=payload.title,
description=payload.description,
severity=payload.severity if payload.severity in ("low", "medium", "high") else "medium",
corrective_action=payload.corrective_action,
photo_path=payload.photo_id,
)
db.add(defect)
await db.commit()
return {"data": _defect_dict(defect)}
class ResolveIn(BaseModel):
resolution_notes: str = ""
@router.post("/defects/{defect_id}/resolve")
async def resolve_defect(
defect_id: uuid.UUID,
payload: ResolveIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
defect = await db.get(Defect, defect_id)
if defect is None:
raise NotFoundError("عیب یافت نشد")
await scoped_task(db, ctx, defect.task_id)
if defect.status == "resolved":
raise ValidationError("این عیب قبلاً رفع شده است")
defect.status = "resolved"
defect.resolution_notes = payload.resolution_notes.strip()
defect.resolved_at = datetime.now(timezone.utc)
await db.commit()
return {"data": _defect_dict(defect)}
@router.get("/defects")
async def list_defects(
task_id: uuid.UUID | None = None,
status: str | None = None,
ctx: AuthContext = Depends(view_qc),
db: AsyncSession = Depends(get_db),
):
query = select(Defect).join(Task, Task.id == Defect.task_id)
if task_id:
query = query.where(Defect.task_id == task_id)
if status:
query = query.where(Defect.status == status)
query = ctx.scope(query, Task.project_id)
query = query.order_by(Defect.created_at.desc()).limit(150)
rows = (await db.execute(query)).scalars().all()
items = []
for d in rows:
task = await db.get(Task, d.task_id)
di = _defect_dict(d)
di["task_title"] = task.title if task else ""
items.append(di)
return {"data": items}
async def defects_for_task(db: AsyncSession, task_id: uuid.UUID) -> list[Defect]:
rows = (
await db.execute(select(Defect).where(Defect.task_id == task_id).order_by(Defect.created_at.desc()))
).scalars().all()
return list(rows)
+834
View File
@@ -0,0 +1,834 @@
"""Tasks: Native PM Construction Platform Tasks & Activities.
Supports:
- Hierarchical tasks (Project -> Phase -> Activity -> Subtask) via self-referencing `parent_id`.
- Dates, duration, status, priorities, progress.
- Full dependency relations & scheduling.
"""
from __future__ import annotations
import uuid
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import AuthContext, Perm, require_permission
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import Project, ProjectMember, Task, TaskPriority, TaskProgressLog, TaskRelation, TaskStatus
from app.qc_service import build_state, definition_dict, load_definitions, state_dict
from app.routers.qc import _defect_dict, defects_for_task
from app.serializers import _iso, native_task_dict, task_relation_dict
from app.utils.date_service import date_service
from app.scheduling_service import sync_project_lifecycle_dates
router = APIRouter(prefix="/tasks", tags=["tasks"])
view_tasks = require_permission(Perm.tasks_view)
manage_tasks = require_permission(Perm.tasks_create)
update_tasks = require_permission(Perm.tasks_update)
progress_tasks = require_permission(Perm.tasks_progress)
class HumanResourceItem(BaseModel):
role: str = Field(..., min_length=1)
count: int = Field(1, ge=1)
class MaterialItem(BaseModel):
name: str = Field(..., min_length=1)
unit: str = Field("", max_length=64)
specifications: list[str] = Field(default_factory=list)
count: float = Field(1.0, ge=0)
class TaskCreateSchema(BaseModel):
project_id: str
parent_id: str | None = None
title: str = Field(..., min_length=1, max_length=512)
description: str = ""
status: str = TaskStatus.TODO.value
priority: str = TaskPriority.MEDIUM.value
assignee_id: str | None = None
planned_start: str | None = None
planned_end: str | None = None
duration: int | None = None
human_resources: list[HumanResourceItem] = Field(default_factory=list)
materials: list[MaterialItem] = Field(default_factory=list)
class TaskUpdateSchema(BaseModel):
title: str | None = None
description: str | None = None
status: str | None = None
priority: str | None = None
assignee: str | None = None
assignee_id: str | None = None
parent_id: str | None = None
planned_start: str | None = None
planned_end: str | None = None
duration: int | None = None
duration_days: int | None = None
human_resources: list[HumanResourceItem] | None = None
materials: list[MaterialItem] | None = None
class HumanResourceConsumption(BaseModel):
role: str = ""
planned_count: int | None = None
actual_count: int = 1
note: str = ""
class MaterialConsumption(BaseModel):
name: str = ""
unit: str = ""
amount: float = 0
allocated_amount: float | None = None
previous_consumed: float | None = None
total_consumed: float | None = None
remaining_amount: float | None = None
is_over_allocated: bool = False
over_allocated_amount: float = 0.0
note: str = ""
class TaskProgressSchema(BaseModel):
percentage: int = Field(..., ge=0, le=100)
human_resources_used: list[HumanResourceConsumption] = Field(default_factory=list)
materials_used: list[MaterialConsumption] = Field(default_factory=list)
description: str = ""
class TaskDatesSchema(BaseModel):
start_date: str | None = None
due_date: str | None = None
async def _resolve_task(db: AsyncSession, ctx: AuthContext, task_id_or_op: str | int | uuid.UUID) -> Task:
"""Find a native task by UUID, enforcing the caller's scope."""
query = (
select(Task)
.options(
selectinload(Task.project),
selectinload(Task.assignee),
selectinload(Task.children),
selectinload(Task.parent),
selectinload(Task.progress_logs),
)
)
try:
val_uuid = uuid.UUID(str(task_id_or_op))
stmt = query.where(Task.id == val_uuid)
except ValueError:
raise NotFoundError("تسک یافت نشد")
task = (await db.execute(stmt)).scalar_one_or_none()
if not task:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_native_project(task.project_id)
return task
async def _validate_task_dates_and_hierarchy(
db: AsyncSession,
task_id: uuid.UUID | None,
planned_start: date | None,
planned_end: date | None,
duration: int | None,
parent_id: uuid.UUID | None,
actual_start: date | None = None,
actual_end: date | None = None,
) -> None:
# 0. اجباری بودن تاریخ شروع و تاریخ پایان
if not planned_start:
raise ValidationError("تاریخ شروع وظیفه الزامی است", field="planned_start")
if not planned_end:
raise ValidationError("تاریخ پایان وظیفه الزامی است", field="planned_end")
# 1. Start date must not be later than end date
if planned_start and planned_end and planned_start > planned_end:
raise ValidationError(
f"تاریخ شروع یک وظیفه ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ اتمام آن ({date_service.to_jalali(planned_end)}) باشد",
field="planned_start",
)
if actual_start and actual_end and actual_start > actual_end:
raise ValidationError(
f"تاریخ شروع واقعی ({date_service.to_jalali(actual_start)}) نمی‌تواند بعد از تاریخ پایان واقعی ({date_service.to_jalali(actual_end)}) باشد",
field="actual_start",
)
# 2. Parent vs Child constraint: Parent must envelop child
if parent_id:
parent = await db.scalar(select(Task).where(Task.id == parent_id))
if parent:
if parent.planned_start and planned_start and planned_start < parent.planned_start:
raise ValidationError(
f"تاریخ شروع وظیفه فرزند ({date_service.to_jalali(planned_start)}) نمی‌تواند قبل از تاریخ شروع وظیفه والد ({date_service.to_jalali(parent.planned_start)}) باشد",
field="planned_start",
)
if parent.planned_end and planned_end and planned_end > parent.planned_end:
raise ValidationError(
f"تاریخ پایان وظیفه فرزند ({date_service.to_jalali(planned_end)}) نمی‌تواند بعد از تاریخ پایان وظیفه والد ({date_service.to_jalali(parent.planned_end)}) باشد",
field="planned_end",
)
if parent.duration and duration and duration > parent.duration:
raise ValidationError(
f"مدت زمان وظیفه والد ({parent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند ({duration} روز) باشد",
field="duration",
)
# 3. If this task is a parent, its time span/duration must be >= all its children
if task_id:
children_stmt = select(Task).where(Task.parent_id == task_id)
children = (await db.execute(children_stmt)).scalars().all()
for c in children:
if planned_start and c.planned_start and planned_start > c.planned_start:
raise ValidationError(
f"تاریخ شروع وظیفه والد ({date_service.to_jalali(planned_start)}) نمی‌تواند بعد از تاریخ شروع وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_start)}) باشد",
field="planned_start",
)
if planned_end and c.planned_end and planned_end < c.planned_end:
raise ValidationError(
f"تاریخ پایان وظیفه والد ({date_service.to_jalali(planned_end)}) نمی‌تواند قبل از تاریخ پایان وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_end)}) باشد",
field="planned_end",
)
if duration and c.duration and duration < c.duration:
raise ValidationError(
f"مدت زمان وظیفه والد ({duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «{c.title}» ({c.duration} روز) باشد",
field="duration",
)
@router.get("")
async def list_tasks(
project_id: str | None = None,
parent_id: str | None = None,
search: str | None = None,
status: str | None = None,
qc_status: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(300, ge=1, le=1000),
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""List native tasks with flexible filtering."""
stmt = (
select(Task)
.options(
selectinload(Task.project),
selectinload(Task.assignee),
selectinload(Task.children),
)
.order_by(Task.created_at.asc())
)
# Filter by project
if project_id is not None:
try:
p_uuid = uuid.UUID(project_id)
stmt = stmt.where(Task.project_id == p_uuid)
except ValueError:
pass
# Project scoping for non-managers — native projects are keyed by UUID
stmt = ctx.scope_native(stmt, Task.project_id)
if parent_id is not None:
if parent_id.lower() in ("null", "root", "none"):
stmt = stmt.where(Task.parent_id.is_(None))
else:
stmt = stmt.where(Task.parent_id == uuid.UUID(parent_id))
if search:
stmt = stmt.where(Task.title.ilike(f"%{search.strip()}%"))
if status:
stmt = stmt.where(Task.status == status.upper())
if qc_status:
stmt = stmt.where(Task.qc_status == qc_status)
total = (await db.scalar(select(func.count()).select_from(stmt.subquery()))) or 0
tasks = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
# Count children in memory from loaded tasks to prevent lazy-load I/O
child_counts: dict[uuid.UUID, int] = {}
for t in tasks:
if t.parent_id:
child_counts[t.parent_id] = child_counts.get(t.parent_id, 0) + 1
results = []
for t in tasks:
p_name = t.project.name if t.project else None
c_count = child_counts.get(t.id, 0)
d = native_task_dict(t, project_name=p_name, children_count=c_count)
d["subject"] = t.title
d["percentage_done"] = t.progress
d["start_date"] = _iso(t.planned_start)
d["due_date"] = _iso(t.planned_end)
d["is_closed"] = t.status == TaskStatus.COMPLETED.value
results.append(d)
return {"data": results, "meta": {"total": total}}
@router.post("")
async def create_task(
payload: TaskCreateSchema,
ctx: AuthContext = Depends(manage_tasks),
db: AsyncSession = Depends(get_db),
):
"""Create a new native task / subtask."""
# Resolve project
try:
p_uuid = uuid.UUID(payload.project_id)
project = await db.scalar(select(Project).where(Project.id == p_uuid))
except ValueError:
project = None
if not project:
raise NotFoundError("پروژه یافت نشد")
# Resolve parent task if specified
parent_uuid = None
if payload.parent_id:
parent_task = await _resolve_task(db, ctx, payload.parent_id)
parent_uuid = parent_task.id
p_start = date_service.parse(payload.planned_start)
p_end = date_service.parse(payload.planned_end)
duration = payload.duration or 0
if p_start and p_end:
duration = date_service.calculate_duration(p_start, p_end)
elif p_start and duration:
p_end = date_service.compute_end_date(p_start, duration)
elif p_end and duration:
p_start = p_end - timedelta(days=duration)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=None,
planned_start=p_start,
planned_end=p_end,
duration=duration,
parent_id=parent_uuid,
)
assignee_uuid = None
assignee_name = None
if payload.assignee_id:
try:
assignee_uuid = uuid.UUID(payload.assignee_id)
except ValueError:
assignee_name = payload.assignee_id
task = Task(
project_id=project.id,
parent_id=parent_uuid,
title=payload.title.strip(),
description=payload.description.strip(),
status=payload.status.upper(),
priority=payload.priority.lower(),
assignee_id=assignee_uuid,
assignee_name=assignee_name,
planned_start=p_start,
planned_end=p_end,
duration=duration,
progress=0,
human_resources=[hr.model_dump() for hr in payload.human_resources],
materials=[m.model_dump() for m in payload.materials],
)
db.add(task)
await db.flush()
await sync_project_lifecycle_dates(db, project.id)
await db.commit()
await db.refresh(task)
d = native_task_dict(task, project_name=project.name)
d["subject"] = task.title
d["percentage_done"] = task.progress
return {"data": d}
@router.get("/{id}")
async def get_task_detail(
id: str,
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""Get full task details, including hierarchy, QC state, and defects."""
task = await _resolve_task(db, ctx, id)
# QC definitions and execution state
definitions = await load_definitions(db, task.id)
state = build_state(definitions)
# Defects
defects = await defects_for_task(db, task.id)
children_data = []
for c in task.children:
c_dict = native_task_dict(c, project_name=task.project.name if task.project else None)
c_dict["subject"] = c.title
c_dict["percentage_done"] = c.progress
children_data.append(c_dict)
data = native_task_dict(
task,
project_name=task.project.name if task.project else None,
children_count=len(task.children),
)
data["subject"] = task.title
data["percentage_done"] = task.progress
data["start_date"] = _iso(task.planned_start)
data["due_date"] = _iso(task.planned_end)
data["start_date_jalali"] = date_service.to_jalali(task.planned_start)
data["due_date_jalali"] = date_service.to_jalali(task.planned_end)
data["is_closed"] = task.status == TaskStatus.COMPLETED.value
# Load task relations (predecessors and successors)
rel_stmt = (
select(TaskRelation)
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
.where(
or_(
TaskRelation.from_task_id == task.id,
TaskRelation.to_task_id == task.id,
)
)
)
task_rels = (await db.execute(rel_stmt)).scalars().all()
incoming = []
outgoing = []
for r in task_rels:
rd = task_relation_dict(r)
if r.to_task_id == task.id:
# incoming: predecessor (from_task) -> current task
incoming.append({
**rd,
"id": str(r.id),
"op_wp_id": str(r.from_task_id),
"task_id": str(r.from_task_id),
"subject": r.from_task.title if r.from_task else "",
"relation_type": r.relation_type,
"lag_days": r.lag_days,
})
if r.from_task_id == task.id:
# outgoing: current task -> successor (to_task)
outgoing.append({
**rd,
"id": str(r.id),
"op_wp_id": str(r.to_task_id),
"task_id": str(r.to_task_id),
"subject": r.to_task.title if r.to_task else "",
"relation_type": r.relation_type,
"lag_days": r.lag_days,
})
data["relations"] = {"blocks": outgoing, "blocked_by": incoming}
data["all_relations"] = [task_relation_dict(r) for r in task_rels]
data["children"] = children_data
data["parent"] = (
{"id": str(task.parent.id), "title": task.parent.title} if task.parent else None
)
data["qc"] = state_dict(state, progress=task.progress)
data["qc_definitions"] = [definition_dict(d, state) for d in definitions]
data["defects"] = [_defect_dict(d) for d in defects]
data["open_defects"] = sum(1 for d in defects if d.status != "resolved")
return {"data": data}
@router.patch("/{id}")
async def update_task(
id: str,
payload: TaskUpdateSchema,
ctx: AuthContext = Depends(update_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task title, description, status, priority, or dates."""
task = await _resolve_task(db, ctx, id)
if payload.title is not None:
task.title = payload.title.strip()
if payload.description is not None:
task.description = payload.description.strip()
if payload.status is not None:
task.status = payload.status.upper()
if payload.priority is not None:
task.priority = payload.priority.lower()
if payload.assignee is not None:
task.assignee_name = payload.assignee.strip() or None
if payload.assignee_id is not None:
try:
task.assignee_id = uuid.UUID(payload.assignee_id)
except ValueError:
task.assignee_name = payload.assignee_id
if payload.parent_id is not None:
clean_p = payload.parent_id.strip().lower()
if not clean_p or clean_p in ("none", "null", "root"):
task.parent_id = None
else:
parent = await _resolve_task(db, ctx, payload.parent_id)
if parent.id == task.id:
raise ValidationError("تسک نمی‌تواند والد خودش باشد")
# Prevent cycle: ensure task is not in ancestors of parent
check = parent
while check:
if check.id == task.id:
raise ValidationError("نمی‌توان یک زیرتسک را به عنوان والد انتخاب کرد (وابستگی چرخه‌ای)")
if check.parent_id:
check = await db.scalar(select(Task).where(Task.id == check.parent_id))
else:
break
task.parent_id = parent.id
p_start = task.planned_start
p_end = task.planned_end
a_start = task.actual_start
a_end = task.actual_end
dur = task.duration
if payload.planned_start is not None:
p_start = date_service.parse(payload.planned_start)
if payload.planned_end is not None:
p_end = date_service.parse(payload.planned_end)
dur_input = payload.duration if payload.duration is not None else payload.duration_days
if dur_input is not None:
dur = max(0, dur_input)
if p_start:
p_end = date_service.compute_end_date(p_start, dur)
elif p_start and p_end:
dur = date_service.calculate_duration(p_start, p_end)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=task.id,
planned_start=p_start,
planned_end=p_end,
duration=dur,
parent_id=task.parent_id,
actual_start=a_start,
actual_end=a_end,
)
task.planned_start = p_start
task.planned_end = p_end
task.duration = dur
if payload.human_resources is not None:
task.human_resources = [hr.model_dump() for hr in payload.human_resources]
if payload.materials is not None:
task.materials = [m.model_dump() for m in payload.materials]
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
task = await _resolve_task(db, ctx, task.id)
return {"data": native_task_dict(task, project_name=task.project.name if task.project else None)}
@router.patch("/{id}/progress")
async def update_task_progress(
id: str,
payload: TaskProgressSchema,
ctx: AuthContext = Depends(progress_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task progress (0-100%) with strict server-side QC validation rules."""
task = await _resolve_task(db, ctx, id)
percentage = payload.percentage
# 1. Evaluate QC business rules
definitions = await load_definitions(db, task.id)
state = build_state(definitions)
# Server-side Invariant Rule 1: pre-QC must be satisfied if progress > 0
if percentage > 0 and not state.can_start:
unresolved = state.pre.required_unresolved
msg = (
f"بازرسی پیش از اجرا تأیید نشده است ({unresolved} سؤال باقی‌مانده)"
if unresolved > 0
else "بازرسی پیش از اجرا مردود شده است و نیاز به رفع و بازرسی مجدد دارد"
)
raise ValidationError(msg, field="percentage")
# Server-side Invariant Rule 2: all required QC must be satisfied if progress >= 100
if percentage >= 100 and not state.can_complete:
unresolved = state.all_.required_unresolved
failed = state.all_.failed
raise ValidationError(
f"تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست ({unresolved} سؤال باقی‌مانده، {failed} مورد مردود)",
field="percentage",
)
# 2. Update task progress and lifecycle dates
progress_before = task.progress or 0
task.progress = percentage
if percentage > 0:
if not task.actual_start:
task.actual_start = date_service.today()
if task.status == TaskStatus.TODO.value:
task.status = TaskStatus.IN_PROGRESS.value
else:
# percentage == 0: reset actual dates
task.actual_start = None
task.actual_end = None
task.status = TaskStatus.TODO.value
if percentage >= 100:
task.actual_end = date_service.today()
task.status = TaskStatus.COMPLETED.value
elif 0 < percentage < 100:
task.actual_end = None
if task.status == TaskStatus.COMPLETED.value:
task.status = TaskStatus.IN_PROGRESS.value
# 3. Log consumption of human resources and materials
hr_list = [h.model_dump() for h in payload.human_resources_used if h.role.strip()]
# Build allocation lookup from task.materials
alloc_map = {}
for item in (task.materials or []):
if isinstance(item, dict):
m_name = (item.get("name") or "").strip().lower()
if m_name:
alloc_map[m_name] = item
# Calculate previously consumed amounts from prior logs
past_consumed_map: dict[str, float] = {}
for plog in (task.progress_logs or []):
for pmat in (plog.materials_used or []):
if isinstance(pmat, dict):
p_name = (pmat.get("name") or "").strip().lower()
if p_name:
past_consumed_map[p_name] = past_consumed_map.get(p_name, 0.0) + float(pmat.get("amount") or 0.0)
mat_list = []
for m in payload.materials_used:
name = m.name.strip()
if not name:
continue
key = name.lower()
matched = alloc_map.get(key)
# Base allocation: explicitly passed or from task.materials
if m.allocated_amount is not None:
alloc_amt = float(m.allocated_amount)
elif matched:
alloc_amt = float(matched.get("count") or 0.0)
else:
alloc_amt = 0.0
unit = m.unit.strip() or (matched.get("unit", "") if matched else "")
amount = float(m.amount or 0.0)
prev_consumed = past_consumed_map.get(key, 0.0)
tot_consumed = prev_consumed + amount
rem_amount = max(0.0, alloc_amt - tot_consumed) if alloc_amt > 0 else 0.0
is_over = (tot_consumed > alloc_amt) if alloc_amt > 0 else (tot_consumed > 0)
over_amt = max(0.0, tot_consumed - alloc_amt) if alloc_amt > 0 else tot_consumed
mat_list.append({
"name": name,
"unit": unit,
"amount": amount,
"allocated_amount": alloc_amt,
"previous_consumed": prev_consumed,
"total_consumed": tot_consumed,
"remaining_amount": rem_amount,
"is_over_allocated": is_over,
"over_allocated_amount": over_amt,
"note": m.note.strip() if m.note else "",
})
log = TaskProgressLog(
task_id=task.id,
progress_before=progress_before,
progress_after=percentage,
human_resources_used=hr_list,
materials_used=mat_list,
description=payload.description.strip(),
logged_by=ctx.user.display_name or ctx.user.username,
)
db.add(log)
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
await db.refresh(task)
return {
"data": {
"id": str(task.id),
"progress": task.progress,
"status": task.status,
"qc_status": task.qc_status,
"actual_start": _iso(task.actual_start),
"actual_end": _iso(task.actual_end),
"progress_log": {
"id": str(log.id),
"progress_before": log.progress_before,
"progress_after": log.progress_after,
"human_resources_used": log.human_resources_used,
"materials_used": log.materials_used,
"description": log.description,
"logged_by": log.logged_by,
"created_at": _iso(log.created_at),
},
}
}
@router.get("/{id}/material-audit")
async def get_task_material_audit(
id: str,
ctx: AuthContext = Depends(view_tasks),
db: AsyncSession = Depends(get_db),
):
"""استعلام مقایسه‌ای مصالح تخصیص‌یافته در برابر کل مصارف واقعی و گزارش اضافه مصرف."""
task = await _resolve_task(db, ctx, id)
alloc_map = {}
for m in (task.materials or []):
if isinstance(m, dict):
name = (m.get("name") or "").strip()
if name:
alloc_map[name.lower()] = {
"name": name,
"unit": m.get("unit") or "",
"allocated_amount": float(m.get("count") or 0.0),
"specifications": m.get("specifications") or [],
}
consumption_map: dict[str, dict] = {}
for log in (task.progress_logs or []):
for mu in (log.materials_used or []):
if isinstance(mu, dict):
mname = (mu.get("name") or "").strip()
if not mname:
continue
key = mname.lower()
m_amt = float(mu.get("amount") or 0.0)
m_unit = mu.get("unit") or ""
if key not in consumption_map:
consumption_map[key] = {
"name": mname,
"unit": m_unit,
"total_consumed": 0.0,
}
consumption_map[key]["total_consumed"] += m_amt
all_keys = set(alloc_map.keys()) | set(consumption_map.keys())
audit_items = []
total_over_allocated_count = 0
for key in sorted(all_keys):
alloc = alloc_map.get(key)
consumed = consumption_map.get(key)
name = alloc["name"] if alloc else consumed["name"]
unit = alloc["unit"] if alloc else consumed["unit"]
alloc_amt = alloc["allocated_amount"] if alloc else 0.0
tot_cons = consumed["total_consumed"] if consumed else 0.0
rem_amt = max(0.0, alloc_amt - tot_cons) if alloc_amt > 0 else 0.0
is_over = (tot_cons > alloc_amt) if alloc_amt > 0 else (tot_cons > 0)
over_amt = max(0.0, tot_cons - alloc_amt) if alloc_amt > 0 else tot_cons
if is_over:
total_over_allocated_count += 1
audit_items.append({
"name": name,
"unit": unit,
"allocated_amount": alloc_amt,
"total_consumed": tot_cons,
"remaining_amount": rem_amt,
"is_over_allocated": is_over,
"over_allocated_amount": over_amt,
"specifications": alloc["specifications"] if alloc else [],
})
return {
"data": {
"task_id": str(task.id),
"task_title": task.title,
"has_over_allocation": total_over_allocated_count > 0,
"over_allocated_items_count": total_over_allocated_count,
"items": audit_items,
}
}
@router.patch("/{id}/dates")
async def update_task_dates(
id: str,
payload: TaskDatesSchema,
ctx: AuthContext = Depends(update_tasks),
db: AsyncSession = Depends(get_db),
):
"""Update task dates (dual Jalali/ISO compatible)."""
task = await _resolve_task(db, ctx, id)
p_start = task.planned_start
p_end = task.planned_end
if payload.start_date is not None:
p_start = date_service.parse(payload.start_date)
if payload.due_date is not None:
p_end = date_service.parse(payload.due_date)
dur = task.duration
if p_start and p_end:
dur = date_service.calculate_duration(p_start, p_end)
await _validate_task_dates_and_hierarchy(
db=db,
task_id=task.id,
planned_start=p_start,
planned_end=p_end,
duration=dur,
parent_id=task.parent_id,
actual_start=task.actual_start,
actual_end=task.actual_end,
)
task.planned_start = p_start
task.planned_end = p_end
task.duration = dur
await sync_project_lifecycle_dates(db, task.project_id)
await db.commit()
task = await _resolve_task(db, ctx, task.id)
return {
"data": {
"id": str(task.id),
"start_date": _iso(task.planned_start),
"due_date": _iso(task.planned_end),
"start_date_jalali": date_service.to_jalali(task.planned_start),
"due_date_jalali": date_service.to_jalali(task.planned_end),
"duration": task.duration,
}
}
@router.delete("/{id}")
async def delete_task(
id: str,
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
db: AsyncSession = Depends(get_db),
):
"""Delete a task and its children."""
task = await _resolve_task(db, ctx, id)
project_id = task.project_id
await db.delete(task)
await db.flush()
await sync_project_lifecycle_dates(db, project_id)
await db.commit()
return {"data": {"deleted": True, "id": id}}
+186
View File
@@ -0,0 +1,186 @@
"""User and project-assignment administration (role `project_manager` only).
Creating the accounts that hold a scoped role and deciding *which* projects the
scoped role may touch is what makes the RBAC in app/authz.py usable; nothing
here changes an operational record.
"""
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import ROLE_LABELS, AuthContext, Perm, Role, require_permission, role_of
from app.db import get_db
from app.dev_accounts import clear_assignments
from app.errors import NotFoundError, ValidationError
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
from app.serializers import _iso
router = APIRouter(prefix="/users", tags=["users"])
manage = require_permission(Perm.users_manage)
def _role_value(value: str) -> str:
try:
return Role(value).value
except ValueError as exc:
raise ValidationError(f"نقش نامعتبر است: {value}", field="role") from exc
async def _assigned_project_ids(db: AsyncSession, user_id) -> list[str]:
"""Projects assigned to a user, as native UUIDs."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
return sorted(str(pid) for pid in rows if pid is not None)
async def _user_dict(db: AsyncSession, user: PMUser) -> dict:
role = role_of(user)
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"role": role.value,
"role_label": ROLE_LABELS.get(role.value, role.value),
"scoped": role in (Role.project_supervisor,),
"project_ids": await _assigned_project_ids(db, user.id),
"created_at": _iso(user.created_at),
}
@router.get("")
async def list_users(ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
users = (await db.execute(select(PMUser).order_by(PMUser.username))).scalars().all()
return {"data": [await _user_dict(db, u) for u in users]}
class UserIn(BaseModel):
username: str
password: str
display_name: str = ""
role: str = Role.project_supervisor.value
@field_validator("username")
@classmethod
def _username(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام کاربری الزامی است")
return v.strip()
@field_validator("password")
@classmethod
def _password(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.post("")
async def create_user(payload: UserIn, ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
existing = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
if existing is not None:
raise ValidationError("این نام کاربری قبلاً ثبت شده است", field="username")
user = PMUser(
username=payload.username,
password_hash=hash_password(payload.password),
display_name=payload.display_name.strip(),
role=_role_value(payload.role),
)
db.add(user)
await db.commit()
return {"data": await _user_dict(db, user)}
class UserPatch(BaseModel):
display_name: str | None = None
role: str | None = None
password: str | None = None
@field_validator("password")
@classmethod
def _password(cls, v: str | None) -> str | None:
if v is not None and len(v) < 6:
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
return v
@router.patch("/{user_id}")
async def patch_user(
user_id: uuid.UUID,
payload: UserPatch,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.password:
user.password_hash = hash_password(payload.password)
if payload.role is not None:
new_role = _role_value(payload.role)
# never let a manager remove their own manager role (lock-out guard)
if user.id == ctx.user.id and new_role != Role.project_manager.value:
raise ValidationError("نقش مدیر فعلی خود را نمی‌توانید تغییر دهید", field="role")
user.role = new_role
await db.commit()
return {"data": await _user_dict(db, user)}
class ProjectsIn(BaseModel):
"""Native project UUIDs (the live world). Legacy integer ids still accepted."""
project_ids: list[str] = []
@router.put("/{user_id}/projects")
async def set_user_projects(
user_id: uuid.UUID,
payload: ProjectsIn,
ctx: AuthContext = Depends(manage),
db: AsyncSession = Depends(get_db),
):
"""Replace a user's assigned projects (the scope a supervisor is limited to)."""
user = await db.get(PMUser, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
native_ids: list[uuid.UUID] = []
legacy_ids: list[int] = []
unknown: list[str] = []
for raw in dict.fromkeys(payload.project_ids):
raw = str(raw).strip()
parsed = None
try:
parsed = uuid.UUID(raw)
except ValueError:
parsed = None
if parsed is not None:
if await db.get(Project, parsed) is None:
unknown.append(raw)
else:
native_ids.append(parsed)
else:
unknown.append(raw)
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await clear_assignments(db, user.id)
for project_uuid in native_ids:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
await db.commit()
return {"data": await _user_dict(db, user)}
+298
View File
@@ -0,0 +1,298 @@
"""Topological Scheduling Engine for Construction Projects.
Features:
- Cycle Detection (DAG validation)
- Forward Pass (Early Start / Early Finish propagation based on FS, SS, FF, SF + lag)
- Backward Pass (Late Start / Late Finish & Float calculation)
- Critical Path Method (CPM) identification
- Summary task rollup (parent tasks envelop child start/end and progress)
"""
from collections import defaultdict, deque
from datetime import date, timedelta
import uuid
from app.utils.date_service import date_service
class CycleError(Exception):
"""Raised when a dependency would introduce a cycle in the project graph."""
pass
def check_for_cycle(
existing_edges: list[tuple[uuid.UUID, uuid.UUID]],
new_from: uuid.UUID,
new_to: uuid.UUID,
) -> bool:
"""Return True if adding (new_from -> new_to) creates a cycle in the directed graph."""
if new_from == new_to:
return True
adj: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list)
for u, v in existing_edges:
adj[u].append(v)
adj[new_from].append(new_to)
visited: set[uuid.UUID] = set()
rec_stack: set[uuid.UUID] = set()
def dfs(node: uuid.UUID) -> bool:
visited.add(node)
rec_stack.add(node)
for neighbor in adj.get(node, []):
if neighbor not in visited:
if dfs(neighbor):
return True
elif neighbor in rec_stack:
return True
rec_stack.remove(node)
return False
all_nodes = set(adj.keys())
for node in all_nodes:
if node not in visited:
if dfs(node):
return True
return False
def calculate_schedule(
tasks: list,
relations: list,
project_start: date | None = None,
) -> dict:
"""Perform CPM forward and backward pass on tasks according to relations.
Returns dict with:
- tasks_schedule: {task_id: {"planned_start": date, "planned_end": date, "duration": int, "is_critical": bool, "float_days": int}}
- critical_path: list of task_ids
"""
if not tasks:
return {"tasks_schedule": {}, "critical_path": []}
task_map = {t.id: t for t in tasks}
in_degree: dict[uuid.UUID, int] = {t.id: 0 for t in tasks}
adj: dict[uuid.UUID, list] = defaultdict(list)
reverse_adj: dict[uuid.UUID, list] = defaultdict(list)
for rel in relations:
if rel.from_task_id in task_map and rel.to_task_id in task_map:
adj[rel.from_task_id].append(rel)
reverse_adj[rel.to_task_id].append(rel)
in_degree[rel.to_task_id] += 1
# 1. Topological Sort via Kahn's algorithm
queue = deque([tid for tid, deg in in_degree.items() if deg == 0])
topo_order: list[uuid.UUID] = []
while queue:
u = queue.popleft()
topo_order.append(u)
for rel in adj[u]:
v = rel.to_task_id
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
if len(topo_order) < len(tasks):
# Fallback if a cycle somehow exists: process in default order
topo_order = [t.id for t in tasks]
# Baseline anchor date
base_date = project_start or date_service.today()
# 2. Forward Pass (Calculate Early Start ES and Early Finish EF)
es: dict[uuid.UUID, date] = {}
ef: dict[uuid.UUID, date] = {}
durations: dict[uuid.UUID, int] = {}
for tid in topo_order:
t = task_map[tid]
# مدت = فاصلهٔ روزها؛ صفر یعنی شروع و پایان همان روز
duration = max(t.duration or 0, 0)
durations[tid] = duration
# Default start from current task planned_start or project base
initial_start = t.planned_start or base_date
incoming = reverse_adj.get(tid, [])
if not incoming:
es[tid] = initial_start
else:
cand_starts = [initial_start]
for rel in incoming:
p_id = rel.from_task_id
p_ef = ef.get(p_id, initial_start)
p_es = es.get(p_id, initial_start)
lag = rel.lag_days or 0
rtype = (rel.relation_type or "FS").upper()
if rtype == "FS":
# Successor starts after Predecessor finishes + lag
cand_starts.append(p_ef + timedelta(days=lag))
elif rtype == "SS":
# Successor starts after Predecessor starts + lag
cand_starts.append(p_es + timedelta(days=lag))
elif rtype == "FF":
# Successor finishes after Predecessor finishes + lag -> adjust start
needed_ef = p_ef + timedelta(days=lag)
cand_starts.append(needed_ef - timedelta(days=duration))
elif rtype == "SF":
# Successor finishes after Predecessor starts + lag
needed_ef = p_es + timedelta(days=lag)
cand_starts.append(needed_ef - timedelta(days=duration))
es[tid] = max(cand_starts)
ef[tid] = es[tid] + timedelta(days=duration)
# 3. Backward Pass (Calculate Late Start LS, Late Finish LF, Float)
project_max_ef = max(ef.values()) if ef else base_date
lf: dict[uuid.UUID, date] = {}
ls: dict[uuid.UUID, date] = {}
for tid in reversed(topo_order):
duration = durations[tid]
outgoing = adj.get(tid, [])
if not outgoing:
lf[tid] = project_max_ef
else:
cand_finishes = []
for rel in outgoing:
s_id = rel.to_task_id
s_ls = ls.get(s_id, project_max_ef)
s_lf = lf.get(s_id, project_max_ef)
lag = rel.lag_days or 0
rtype = (rel.relation_type or "FS").upper()
if rtype == "FS":
cand_finishes.append(s_ls - timedelta(days=lag))
elif rtype == "SS":
needed_s_start = s_ls - timedelta(days=lag)
cand_finishes.append(needed_s_start + timedelta(days=duration))
elif rtype == "FF":
cand_finishes.append(s_lf - timedelta(days=lag))
elif rtype == "SF":
needed_s_finish = s_lf - timedelta(days=lag)
cand_finishes.append(needed_s_finish + timedelta(days=duration))
lf[tid] = min(cand_finishes) if cand_finishes else project_max_ef
ls[tid] = lf[tid] - timedelta(days=duration)
# 4. Critical Path Identification (Total Float == 0)
critical_path: list[uuid.UUID] = []
tasks_schedule: dict[uuid.UUID, dict] = {}
for tid in topo_order:
t_es = es[tid]
t_ef = ef[tid]
t_ls = ls.get(tid, t_es)
t_lf = lf.get(tid, t_ef)
float_days = (t_ls - t_es).days
is_critical = float_days <= 0
if is_critical:
critical_path.append(tid)
tasks_schedule[tid] = {
"planned_start": t_es,
"planned_end": t_ef,
"duration": durations[tid],
"float_days": max(float_days, 0),
"is_critical": is_critical,
}
return {
"tasks_schedule": tasks_schedule,
"critical_path": critical_path,
"project_finish": project_max_ef,
}
def rollup_parent_tasks(tasks: list) -> dict[uuid.UUID, dict]:
"""Roll up dates and progress from children to parent tasks."""
parent_map: dict[uuid.UUID, list] = defaultdict(list)
task_map = {t.id: t for t in tasks}
for t in tasks:
if t.parent_id and t.parent_id in task_map:
parent_map[t.parent_id].append(t)
rollups: dict[uuid.UUID, dict] = {}
for pid, children in parent_map.items():
child_starts = [c.planned_start for c in children if c.planned_start]
child_ends = [c.planned_end for c in children if c.planned_end]
child_progs = [c.progress or 0 for c in children]
p_start = min(child_starts) if child_starts else None
p_end = max(child_ends) if child_ends else None
p_dur = None
if p_start and p_end:
p_dur = date_service.calculate_duration(p_start, p_end)
rollups[pid] = {
"planned_start": p_start,
"planned_end": p_end,
"duration": p_dur,
"progress": round(sum(child_progs) / len(child_progs)) if child_progs else 0,
}
return rollups
async def sync_project_lifecycle_dates(db, project_id: uuid.UUID):
"""Synchronize Project planned_start, planned_end, actual_start, actual_end, and progress from its tasks.
Business rules:
- planned_start: planned_start of the first task (earliest planned_start)
- planned_end: planned_end of the last task (latest planned_end)
- actual_start: actual_start of the first task (or earliest actual_start across tasks)
- actual_end: actual_end of the last task
"""
from sqlalchemy import select
from app.models import Project, Task
project = await db.get(Project, project_id)
if not project:
return None
tasks_result = await db.execute(select(Task).where(Task.project_id == project_id))
tasks = tasks_result.scalars().all()
if not tasks:
project.planned_start = None
project.planned_end = None
project.actual_start = None
project.actual_end = None
project.progress = 0.0
return project
valid_starts = [t for t in tasks if t.planned_start is not None]
valid_ends = [t for t in tasks if t.planned_end is not None]
if valid_starts:
first_task = min(valid_starts, key=lambda t: (t.planned_start, t.created_at))
project.planned_start = first_task.planned_start
all_actual_starts = [t.actual_start for t in tasks if t.actual_start is not None]
project.actual_start = first_task.actual_start or (min(all_actual_starts) if all_actual_starts else None)
else:
project.planned_start = None
all_actual_starts = [t.actual_start for t in tasks if t.actual_start is not None]
project.actual_start = min(all_actual_starts) if all_actual_starts else None
if valid_ends:
last_task = max(valid_ends, key=lambda t: (t.planned_end, t.created_at))
project.planned_end = last_task.planned_end
project.actual_end = last_task.actual_end
else:
project.planned_end = None
project.actual_end = None
progs = [t.progress or 0 for t in tasks]
if progs:
project.progress = round(sum(progs) / len(progs), 1)
return project
+142
View File
@@ -0,0 +1,142 @@
"""Lightweight schema sync for additive/structural changes.
`Base.metadata.create_all` creates NEW tables but never alters existing ones,
so this module applies the missing pieces at startup:
1. `drop_legacy()` — tables whose shape changed or whose feature was removed
(including all legacy cache & sync tables).
2. `ensure_schema()` — additive columns and new enum values.
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
logger = logging.getLogger("pm.schema")
# Tables dropped on startup (idempotent):
# - legacy cache & sync tables completely purged
# - legacy inspection / activity template tables
LEGACY_TABLES: list[str] = [
"material_requirements",
"reservations",
"milestones",
"purchase_requests",
"inspection_results",
"inspections",
"activity_template_materials",
"activity_templates",
"projects_cache",
"task_refs",
"sync_events",
"integration_logs",
"webhook_deliveries",
"sync_state",
]
LEGACY_COLUMNS: list[tuple[str, str]] = [
("projects", "client"),
("projects", "code"),
("projects", "erp_project_id"),
("tasks", "planned_progress"),
("projects", "legacy_op_project_id"),
("tasks", "legacy_op_wp_id"),
("task_qc_definitions", "task_op_wp_id"),
("defects", "task_op_wp_id"),
("project_assignments", "op_project_id"),
("tasks", "equipment"),
]
ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [
# (table, column, type)
# RBAC: users that predate roles keep their previous (manager) access
("pm_users", "role", "VARCHAR(32) NOT NULL DEFAULT 'project_manager'"),
# Native PM Platform linking
("task_qc_definitions", "task_id", "UUID"),
("defects", "task_id", "UUID"),
("project_assignments", "project_id", "UUID"),
("tasks", "human_resources", "JSONB DEFAULT '[]'"),
("tasks", "materials", "JSONB DEFAULT '[]'"),
("task_qc_results", "photo_path", "VARCHAR(512)"),
]
ENUM_VALUES: list[tuple[str, str]] = [
# (enum type, value) — e.g. the pre-execution QC stage
("qc_phase", "pre"),
]
async def drop_legacy(conn: AsyncConnection) -> None:
# Migrate any existing equipment data to materials before dropping the equipment column
try:
check = await conn.execute(
text("SELECT 1 FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'equipment'")
)
if check.scalar():
await conn.execute(
text("""
UPDATE tasks
SET materials = COALESCE(materials, '[]'::jsonb) || (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'name', elem->>'name',
'unit', COALESCE(elem->>'unit', 'دستگاه'),
'specifications', COALESCE(elem->'specifications', '[]'::jsonb),
'count', COALESCE((elem->>'count')::numeric, 1)
)
), '[]'::jsonb)
FROM jsonb_array_elements(equipment) AS elem
WHERE elem->>'name' IS NOT NULL AND elem->>'name' != ''
)
WHERE equipment IS NOT NULL AND jsonb_array_length(equipment) > 0;
""")
)
logger.info("Migrated equipment data into materials in tasks table.")
except Exception as exc: # noqa: BLE001
logger.warning("equipment migration skipped: %s", exc)
for table in LEGACY_TABLES:
try:
await conn.execute(text(f"DROP TABLE IF EXISTS {table} CASCADE"))
except Exception as exc: # noqa: BLE001 — never block startup on this
logger.warning("legacy drop skipped %s: %s", table, exc)
for table, column in LEGACY_COLUMNS:
try:
await conn.execute(text(f"ALTER TABLE IF EXISTS {table} DROP COLUMN IF EXISTS {column} CASCADE"))
except Exception as exc: # noqa: BLE001
logger.warning("legacy column drop skipped %s.%s: %s", table, column, exc)
async def ensure_schema(conn: AsyncConnection) -> None:
for enum_type, value in ENUM_VALUES:
try:
await conn.execute(text(f"ALTER TYPE {enum_type} ADD VALUE IF NOT EXISTS '{value}'"))
except Exception as exc: # noqa: BLE001
logger.warning("enum sync skipped %s.%s: %s", enum_type, value, exc)
for table, column, coltype in ADDITIVE_COLUMNS:
try:
await conn.execute(text(f"ALTER TABLE IF EXISTS {table} ADD COLUMN IF NOT EXISTS {column} {coltype}"))
except Exception as exc: # noqa: BLE001
logger.warning("schema sync skipped %s.%s: %s", table, column, exc)
try:
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS task_progress_logs (
id UUID PRIMARY KEY,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
progress_before INTEGER DEFAULT 0,
progress_after INTEGER NOT NULL,
human_resources_used JSONB DEFAULT '[]',
materials_used JSONB DEFAULT '[]',
description TEXT DEFAULT '',
logged_by VARCHAR(255) DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
"""))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_progress_logs_task ON task_progress_logs (task_id)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_progress_logs_created ON task_progress_logs (created_at)"))
except Exception as exc: # noqa: BLE001
logger.warning("task_progress_logs table sync skipped: %s", exc)
+89
View File
@@ -0,0 +1,89 @@
"""Local auth — scrypt password hashing + HMAC-signed session tokens.
Deliberately simple for the MVP (single role). Casdoor SSO can be added later
the same way the ERP does it.
"""
import base64
import hashlib
import hmac
import json
import os
import time
import uuid
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.casdoor_auth import casdoor_user_from_token, is_casdoor_token
from app.config import settings
from app.db import get_db
from app.errors import AuthError
from app.models import PMUser
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
return f"scrypt${salt.hex()}${dk.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, salt_hex, dk_hex = stored.split("$")
dk = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt_hex), n=2**14, r=8, p=1)
return hmac.compare_digest(dk.hex(), dk_hex)
except Exception:
return False
def _sign(payload: bytes) -> str:
return hmac.new(settings.SESSION_SECRET.encode(), payload, hashlib.sha256).hexdigest()
def create_token(user_id: uuid.UUID) -> str:
payload = json.dumps(
{"uid": str(user_id), "exp": int(time.time()) + settings.SESSION_TTL_HOURS * 3600}
).encode()
body = base64.urlsafe_b64encode(payload).rstrip(b"=")
return f"{body.decode()}.{_sign(body)}"
def verify_token(token: str) -> uuid.UUID:
try:
body_b64, sig = token.split(".", 1)
body = body_b64.encode()
if not hmac.compare_digest(_sign(body), sig):
raise ValueError("bad signature")
payload = json.loads(base64.urlsafe_b64decode(body_b64 + "=" * (-len(body_b64) % 4)))
if payload.get("exp", 0) < time.time():
raise ValueError("expired")
return uuid.UUID(payload["uid"])
except AuthError:
raise
except Exception as exc:
raise AuthError("نشست نامعتبر یا منقضی شده است") from exc
async def get_current_user(
request: Request, db: AsyncSession = Depends(get_db)
) -> PMUser:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
raise AuthError("احراز هویت لازم است")
token = auth.split(" ", 1)[1].strip()
if is_casdoor_token(token):
# SSO — the same Casdoor JWT the ERP issued (one login for the suite)
return await casdoor_user_from_token(token, db)
user_id = verify_token(token)
user = await db.get(PMUser, user_id)
if user is None:
raise AuthError("کاربر یافت نشد")
return user
async def authenticate(db: AsyncSession, username: str, password: str) -> PMUser | None:
user = await db.scalar(select(PMUser).where(PMUser.username == username.strip()))
if user is None or not verify_password(password, user.password_hash):
return None
return user
+158
View File
@@ -0,0 +1,158 @@
"""Response serializers — the {"data": ...} envelope lives in the routers."""
from datetime import date, datetime
from app.utils.jalali import to_jalali_str
def _iso(value: datetime | date | None) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return value.isoformat()
def user_payload(ctx) -> dict:
"""The authenticated user as the frontend sees them: identity, role and the
permissions/scope the UI derives its navigation from (see frontend
src/lib/roles.ts). The API enforces the same values server-side."""
user = ctx.user
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
**ctx.as_dict(),
}
def project_dict(project, member_count: int = 0, task_stats: dict | None = None) -> dict:
return {
"id": str(project.id),
"name": project.name,
"description": project.description or "",
"location": project.location or "",
"project_manager": project.project_manager or "",
"planned_start": _iso(project.planned_start),
"planned_end": _iso(project.planned_end),
"actual_start": _iso(project.actual_start),
"actual_end": _iso(project.actual_end),
"planned_start_jalali": to_jalali_str(project.planned_start),
"planned_end_jalali": to_jalali_str(project.planned_end),
"actual_start_jalali": to_jalali_str(project.actual_start),
"actual_end_jalali": to_jalali_str(project.actual_end),
"status": project.status,
"progress": round(project.progress or 0.0, 1),
"member_count": member_count,
"stats": task_stats or {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0},
"created_at": _iso(project.created_at),
"updated_at": _iso(project.updated_at),
}
def project_member_dict(member) -> dict:
username = ""
display_name = ""
try:
from sqlalchemy.inspection import inspect
insp = inspect(member)
if "user" in insp.attrs and insp.attrs.user.loaded and member.user:
username = member.user.username
display_name = member.user.display_name
except Exception:
pass
return {
"id": str(member.id),
"project_id": str(member.project_id),
"user_id": str(member.user_id),
"username": username,
"display_name": display_name,
"role": member.role,
"created_at": _iso(member.created_at),
}
def native_task_dict(task, project_name: str | None = None, children_count: int = 0) -> dict:
assignee_name = task.assignee_name
if not assignee_name:
try:
from sqlalchemy.inspection import inspect
insp = inspect(task)
if "assignee" in insp.attrs and "assignee" not in insp.unloaded and task.assignee:
assignee_name = task.assignee.display_name
except Exception:
pass
progress_logs_data = []
try:
from sqlalchemy.inspection import inspect
insp = inspect(task)
if "progress_logs" in insp.attrs and "progress_logs" not in insp.unloaded and task.progress_logs:
progress_logs_data = [
{
"id": str(log.id),
"progress_before": log.progress_before,
"progress_after": log.progress_after,
"human_resources_used": log.human_resources_used or [],
"materials_used": log.materials_used or [],
"description": log.description or "",
"logged_by": log.logged_by or "",
"created_at": _iso(log.created_at),
}
for log in task.progress_logs
]
except Exception:
pass
return {
"id": str(task.id),
"project_id": str(task.project_id),
"parent_id": str(task.parent_id) if task.parent_id else None,
"title": task.title,
"description": task.description or "",
"status": task.status,
"priority": task.priority,
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee_name": assignee_name,
"planned_start": _iso(task.planned_start),
"planned_end": _iso(task.planned_end),
"actual_start": _iso(task.actual_start),
"actual_end": _iso(task.actual_end),
"planned_start_jalali": to_jalali_str(task.planned_start),
"planned_end_jalali": to_jalali_str(task.planned_end),
"actual_start_jalali": to_jalali_str(task.actual_start),
"actual_end_jalali": to_jalali_str(task.actual_end),
"duration": task.duration or 1,
"progress": task.progress or 0,
"qc_status": task.qc_status or "none",
"project_name": project_name,
"human_resources": getattr(task, "human_resources", None) or [],
"materials": getattr(task, "materials", None) or [],
"progress_logs": progress_logs_data,
"created_at": _iso(task.created_at),
"updated_at": _iso(task.updated_at),
}
def task_relation_dict(rel, from_title: str = "", to_title: str = "") -> dict:
from_title_val = from_title
to_title_val = to_title
try:
from sqlalchemy.inspection import inspect
insp = inspect(rel)
if not from_title_val and "from_task" in insp.attrs and insp.attrs.from_task.loaded and rel.from_task:
from_title_val = rel.from_task.title
if not to_title_val and "to_task" in insp.attrs and insp.attrs.to_task.loaded and rel.to_task:
to_title_val = rel.to_task.title
except Exception:
pass
return {
"id": str(rel.id),
"project_id": str(rel.project_id),
"from_task_id": str(rel.from_task_id),
"to_task_id": str(rel.to_task_id),
"from_task_title": from_title_val,
"to_task_title": to_title_val,
"relation_type": rel.relation_type,
"lag_days": rel.lag_days,
"created_at": _iso(rel.created_at),
}
+1
View File
@@ -0,0 +1 @@
"""Shared utilities (Jalali dates, ...)."""
+97
View File
@@ -0,0 +1,97 @@
"""Unified DateService — single source of truth for date handling across PM.
Design invariants:
- Internally: Gregorian datetime.date (UTC-safe, no timezone shift).
- API input/output: ISO 8601 (YYYY-MM-DD) or Jalali (YYYY/MM/DD) when convenient.
- UI: Jalali (Shamsi) display with English/Persian digit support.
"""
from __future__ import annotations
import datetime as _dt
from typing import Any
from app.utils.jalali import (
days_in_month,
gregorian_to_jalali,
is_leap_jalali,
jalali_to_gregorian,
parse_flexible_date,
to_jalali_str,
)
class DateService:
@staticmethod
def parse(value: Any) -> _dt.date | None:
"""Parse ISO, Jalali, or datetime into a standard Gregorian date."""
if value is None:
return None
if isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
return value
if isinstance(value, _dt.datetime):
return value.date()
return parse_flexible_date(str(value))
@staticmethod
def to_iso(value: _dt.date | None) -> str | None:
"""Format date as ISO 8601 string (YYYY-MM-DD)."""
if value is None:
return None
return value.isoformat()
@staticmethod
def today() -> _dt.date:
"""Return today's Gregorian date."""
return _dt.date.today()
@staticmethod
def to_jalali(value: _dt.date | None, sep: str = "/") -> str | None:
"""Format date as Jalali string (YYYY/MM/DD)."""
return to_jalali_str(value, sep=sep)
@staticmethod
def calculate_duration(start_date: _dt.date | None, end_date: _dt.date | None) -> int:
"""Duration in days = the GAP between the two dates (end date not counted).
This is the convention the UI uses, so the number a user types is the
number displayed back to them:
16 → 20 Mordad = 4 days
start == end = 0 days (same day, no span)
Returns 0 when either date is missing or the range is inverted.
"""
if not start_date or not end_date:
return 0
diff = (end_date - start_date).days
return max(0, diff)
@staticmethod
def compute_end_date(start_date: _dt.date | None, duration_days: int) -> _dt.date | None:
"""End date for a start date and a gap-based duration.
Exclusive, the exact inverse of calculate_duration:
compute_end_date(d, calculate_duration(d, e)) == e
calculate_duration(d, compute_end_date(d, n)) == n
A duration of 0 (or 1) means the task starts and ends the same day.
"""
if not start_date:
return None
if duration_days <= 0:
return start_date
return start_date + _dt.timedelta(days=duration_days)
@staticmethod
def today() -> _dt.date:
"""Return current Gregorian date."""
return _dt.date.today()
@staticmethod
def today_jalali(sep: str = "/") -> str:
"""Return today as Jalali string."""
return to_jalali_str(_dt.date.today(), sep=sep) or ""
date_service = DateService()
+195
View File
@@ -0,0 +1,195 @@
"""Jalali (Shamsi) ↔ Gregorian conversion — the ONLY date-conversion module.
Ported from the well-known `jalaali-js` algorithm (MIT), dependency-free.
Design notes:
- Conversions work on plain (year, month, day) integers — no timezone or
clock involvement at all, which is what makes date-only fields safe
(no off-by-one-day bugs).
- PM stores standard ISO dates (Gregorian) and converts only
at the boundaries: parsing user input (Jalali → ISO) and rendering responses
(`*_jalali` convenience fields). The frontend has its own mirrored copy of
this algorithm in `frontend/src/lib/jalali.ts` for pickers/display.
"""
from __future__ import annotations
import datetime as _dt
import re
_BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178,
]
def _div(a: int, b: int) -> int:
"""Truncating integer division (matches the JS reference implementation)."""
q = abs(a) // abs(b)
return q if (a >= 0) == (b >= 0) else -q
def _mod(a: int, b: int) -> int:
"""Remainder with the sign of the dividend (JS `%` semantics)."""
return a - _div(a, b) * b
def _jal_cal(jy: int, without_leap: bool = False) -> tuple[int, int, int]:
"""(leap, gy, march) for a Jalali year."""
bl = len(_BREAKS)
gy = jy + 621
leap_j = -14
jp = _BREAKS[0]
jump = 0
if jy < jp or jy >= _BREAKS[bl - 1]:
raise ValueError(f"سال جلالی نامعتبر: {jy}")
for i in range(1, bl):
jm = _BREAKS[i]
jump = jm - jp
if jy < jm:
break
leap_j += _div(jump, 33) * 8 + _div(_mod(jump, 33), 4)
jp = jm
n = jy - jp
leap_j += _div(n, 33) * 8 + _div(_mod(n, 33) + 3, 4)
if _mod(jump, 33) == 4 and jump - n == 4:
leap_j += 1
leap_g = _div(gy, 4) - _div((_div(gy, 100) + 1) * 3, 4) - 150
march = 20 + leap_j - leap_g
leap = 0
if not without_leap:
if jump - n < 6:
n = n - jump + _div(jump + 4, 33) * 33
leap = _mod(_mod(n + 1, 33) - 1, 4)
if leap == -1:
leap = 4
return leap, gy, march
def _g2d(gy: int, gm: int, gd: int) -> int:
"""Gregorian date → Julian Day Number."""
d = (
_div((gy + _div(gm - 8, 6) + 100100) * 1461, 4)
+ _div(153 * _mod(gm + 9, 12) + 2, 5)
+ gd
- 34840408
)
d = d - _div(_div(gy + 100100 + _div(gm - 8, 6), 100) * 3, 4) + 752
return d
def _d2g(jdn: int) -> tuple[int, int, int]:
"""Julian Day Number → Gregorian date."""
j = 4 * jdn + 139361631
j = j + _div(_div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908
i = _div(_mod(j, 1461), 4) * 5 + 308
gd = _div(_mod(i, 153), 5) + 1
gm = _mod(_div(i, 153), 12) + 1
gy = _div(j, 1461) - 100100 + _div(8 - gm, 6)
return gy, gm, gd
def _j2d(jy: int, jm: int, jd: int) -> int:
"""Jalali date → Julian Day Number."""
_, gy, march = _jal_cal(jy, True)
return _g2d(gy, 3, march) + (jm - 1) * 31 - _div(jm, 7) * (jm - 7) + jd - 1
def _d2j(jdn: int) -> tuple[int, int, int]:
"""Julian Day Number → Jalali date."""
gy, _, _ = _d2g(jdn)
jy = gy - 621
leap, _, march = _jal_cal(jy, False)
jdn1f = _g2d(gy, 3, march)
k = jdn - jdn1f
if k >= 0:
if k <= 185:
return jy, 1 + _div(k, 31), _mod(k, 31) + 1
k -= 186
else:
jy -= 1
k += 179
if leap == 1:
k += 1
return jy, 7 + _div(k, 30), _mod(k, 30) + 1
# --- public API ---------------------------------------------------------------
def gregorian_to_jalali(value: _dt.date) -> tuple[int, int, int]:
"""date(2026, 9, 14) → (1405, 6, 23)"""
return _d2j(_g2d(value.year, value.month, value.day))
def is_leap_jalali(jy: int) -> bool:
return _jal_cal(jy, False)[0] == 0
def days_in_month(jy: int, jm: int) -> int:
if jm <= 6:
return 31
if jm <= 11:
return 30
return 30 if is_leap_jalali(jy) else 29
def jalali_to_gregorian(jy: int, jm: int, jd: int) -> _dt.date:
"""(1405, 6, 23) → date(2026, 9, 14)"""
if not 1 <= jm <= 12:
raise ValueError(f"ماه جلالی نامعتبر است: {jm}")
if not 1 <= jd <= days_in_month(jy, jm):
raise ValueError(f"روز جلالی نامعتبر است: {jy}/{jm:02d}/{jd:02d}")
gy, gm, gd = _d2g(_j2d(jy, jm, jd))
return _dt.date(gy, gm, gd)
def to_jalali_str(value: _dt.date | None, sep: str = "/") -> str | None:
"""date → «1405/06/23» (Latin digits, zero-padded)."""
if value is None:
return None
jy, jm, jd = gregorian_to_jalali(value)
return f"{jy:04d}{sep}{jm:02d}{sep}{jd:02d}"
_ISO_RE = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})$")
_JALALI_RE = re.compile(r"^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$")
_JALALI_COMPACT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
# Gregorian years PM can plausibly see; anything lower is treated as Jalali.
_GREGORIAN_MIN_YEAR = 1700
def parse_flexible_date(value: str | None) -> _dt.date | None:
"""Accept ISO (`2026-09-14`), Jalali (`1405/06/23`, `1405-6-23`) or Jalali
compact (`14050623`) and return a Gregorian date.
Disambiguation: a 4-digit year >= 1700 is Gregorian, otherwise Jalali.
ISO datetime strings (`2026-09-14T10:00:00Z`) are truncated to the date.
"""
if value is None:
return None
text = str(value).strip()
if not text:
return None
# ISO datetime → date part
if "T" in text:
text = text.split("T", 1)[0]
match = _ISO_RE.match(text)
if match:
year, month, day = (int(g) for g in match.groups())
if year >= _GREGORIAN_MIN_YEAR:
return _dt.date(year, month, day)
match = _JALALI_RE.match(text) or _JALALI_COMPACT_RE.match(text)
if match:
jy, jm, jd = (int(g) for g in match.groups())
if jy >= _GREGORIAN_MIN_YEAR: # e.g. 2026/09/14 written with slashes
return _dt.date(jy, jm, jd)
return jalali_to_gregorian(jy, jm, jd)
try:
return _dt.date.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"قالب تاریخ نامعتبر است: {value}") from exc
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
pydantic==2.10.4
pydantic-settings==2.7.0
httpx==0.28.1
python-multipart==0.0.20
alembic==1.14.0
pytest==8.3.4
pytest-asyncio==0.24.0
aiosqlite==0.20.0
pyjwt[crypto]>=2.8.0
@@ -0,0 +1,94 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db import get_db
from app.main import app
from app.models import Base, PMUser, Project, Task
from app.security import create_token, hash_password
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.asyncio
async def test_gantt_and_relations_api():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
admin = PMUser(
username="admin",
password_hash=hash_password("admin123"),
display_name="مدیر ارشد",
role="project_manager",
)
session.add(admin)
await session.flush()
project = Project(name="پروژه تست گانت", description="تست")
session.add(project)
await session.flush()
task1 = Task(project_id=project.id, title="فونداسیون", duration=5)
task2 = Task(project_id=project.id, title="اسکلت بتنی", duration=10)
session.add_all([task1, task2])
await session.commit()
admin_id = admin.id
p_id = str(project.id)
t1_id = str(task1.id)
t2_id = str(task2.id)
token = create_token(admin_id)
headers = {"Authorization": f"Bearer {token}"}
async def override_get_db():
async with async_session() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
# 1. Add relation: task1 -> task2 (FS + 2 days lag)
rel_res = await client.post(
f"/api/v1/projects/{p_id}/relations",
json={"from_task_id": t1_id, "to_task_id": t2_id, "relation_type": "FS", "lag_days": 2},
)
assert rel_res.status_code == 200
rel_data = rel_res.json()["data"]
assert rel_data["relation_type"] == "FS"
assert rel_data["lag_days"] == 2
rel_id = rel_data["id"]
# 2. Add reverse relation: task2 -> task1 (should detect cycle and reject with 400)
cycle_res = await client.post(
f"/api/v1/projects/{p_id}/relations",
json={"from_task_id": t2_id, "to_task_id": t1_id, "relation_type": "FS"},
)
assert cycle_res.status_code == 400
assert "چرخه" in cycle_res.json()["error"]["message"]
# 3. Query Gantt dataset
gantt_res = await client.get(f"/api/v1/projects/{p_id}/gantt")
assert gantt_res.status_code == 200
gantt_data = gantt_res.json()["data"]
assert len(gantt_data["tasks"]) == 2
assert len(gantt_data["relations"]) == 1
assert len(gantt_data["critical_path"]) >= 1
# 4. Delete relation
del_rel = await client.delete(f"/api/v1/projects/{p_id}/relations/{rel_id}")
assert del_rel.status_code == 200
assert del_rel.json()["data"]["deleted"] is True
finally:
app.dependency_overrides.clear()
await engine.dispose()
+207
View File
@@ -0,0 +1,207 @@
"""API tests for native Projects and Tasks endpoints."""
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db import get_db
from app.main import app
from app.models import Base, PMUser, Project, ProjectStatus, Task
from app.security import create_token, hash_password
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.asyncio
async def test_native_project_and_task_api():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Seed admin user
async with async_session() as session:
admin = PMUser(
username="admin",
password_hash=hash_password("admin123"),
display_name="مدیر ارشد",
role="project_manager",
)
session.add(admin)
await session.commit()
admin_id = admin.id
token = create_token(admin_id)
headers = {"Authorization": f"Bearer {token}"}
async def override_get_db():
async with async_session() as s:
yield s
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
# 1. Create a native project
resp = await client.post(
"/api/v1/projects",
json={
"name": "پروژه بیمارستان",
"description": "احداث فاز ۲ بیمارستان",
"location": "تهران",
"status": "ACTIVE",
"planned_start": "1405/01/01",
"planned_end": "1405/12/29",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
proj_data = resp.json()["data"]
project_id = proj_data["id"]
assert proj_data["name"] == "پروژه بیمارستان"
assert proj_data["status"] == "ACTIVE"
# 2. List projects
resp = await client.get("/api/v1/projects", headers=headers)
assert resp.status_code == 200
projects_list = resp.json()["data"]
assert len(projects_list) == 1
assert projects_list[0]["id"] == project_id
# 3. Create a root task (Phase)
resp = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "فاز ۱: اسکلت‌بندی",
"priority": "high",
"planned_start": "1405/01/01",
"duration": 60,
},
headers=headers,
)
assert resp.status_code == 200, resp.text
phase_task = resp.json()["data"]
phase_id = phase_task["id"]
assert phase_task["title"] == "فاز ۱: اسکلت‌بندی"
# 4. Create a child task (Subtask)
resp = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"parent_id": phase_id,
"title": "فعالیت ۱.۱: آرماتوربندی ستون‌ها",
"priority": "urgent",
"planned_start": "1405/01/05",
"duration": 15,
},
headers=headers,
)
assert resp.status_code == 200, resp.text
subtask = resp.json()["data"]
assert subtask["parent_id"] == phase_id
# 5. Get Phase task detail and verify children
resp = await client.get(f"/api/v1/tasks/{phase_id}", headers=headers)
assert resp.status_code == 200
detail = resp.json()["data"]
assert len(detail["children"]) == 1
assert detail["children"][0]["id"] == subtask["id"]
# 6. Update task progress with QC invariant rule
# Since no QC questions exist for this task, progress can increase directly
resp = await client.patch(
f"/api/v1/tasks/{subtask['id']}/progress",
json={"percentage": 50},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["progress"] == 50
assert resp.json()["data"]["status"] == "IN_PROGRESS"
# 7. Verify mandatory start/end dates: task without dates must be rejected
resp_no_dates = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "وظیفه بدون تاریخ",
},
headers=headers,
)
assert resp_no_dates.status_code in (400, 422)
# 8. Verify project creation with simplified fields (no code, client, dates required)
resp_simple_proj = await client.post(
"/api/v1/projects",
json={
"name": "پروژه ساده نوساز",
},
headers=headers,
)
assert resp_simple_proj.status_code == 200
simple_data = resp_simple_proj.json()["data"]
assert simple_data["name"] == "پروژه ساده نوساز"
assert "id" in simple_data
# 9. Verify task creation and update with Human Resources and Materials (including equipment)
resp_resources_task = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "بتن‌ریزی فونداسیون با منابع کامل",
"planned_start": "1405/01/10",
"duration": 5,
"human_resources": [
{"role": "سرکارگر بتن‌ریزی", "count": 1},
{"role": "کارگر ساده", "count": 6},
],
"materials": [
{
"name": "بتن آماده عیار ۳۵۰",
"unit": "متر مکعب",
"specifications": ["تیپ ۲", "اسلامپ ۸"],
"count": 150.5,
},
{
"name": "پمپ بتن دکلی",
"unit": "دستگاه",
"specifications": ["دکل ۳۶ متری"],
"count": 1,
},
],
},
headers=headers,
)
assert resp_resources_task.status_code == 200
res_data = resp_resources_task.json()["data"]
assert len(res_data["human_resources"]) == 2
assert res_data["human_resources"][0]["role"] == "سرکارگر بتن‌ریزی"
assert len(res_data["materials"]) == 2
assert res_data["materials"][0]["unit"] == "متر مکعب"
assert res_data["materials"][0]["specifications"] == ["تیپ ۲", "اسلامپ ۸"]
assert res_data["materials"][1]["name"] == "پمپ بتن دکلی"
# Update resources via PATCH
task_id = res_data["id"]
patch_resp = await client.patch(
f"/api/v1/tasks/{task_id}",
json={
"human_resources": [
{"role": "سرکارگر بتن‌ریزی", "count": 2},
],
},
headers=headers,
)
assert patch_resp.status_code == 200
patched_data = patch_resp.json()["data"]
assert len(patched_data["human_resources"]) == 1
assert patched_data["human_resources"][0]["count"] == 2
app.dependency_overrides.clear()
await engine.dispose()
+48
View File
@@ -0,0 +1,48 @@
"""Unit tests for unified DateService."""
from datetime import date
import pytest
from app.utils.date_service import date_service
def test_date_service_parse_iso():
d = date_service.parse("2026-09-16")
assert d == date(2026, 9, 16)
def test_date_service_parse_jalali():
# 1405/06/25 -> 2026-09-16
d = date_service.parse("1405/06/25")
assert d == date(2026, 9, 16)
def test_date_service_to_jalali():
d = date(2026, 9, 16)
assert date_service.to_jalali(d) == "1405/06/25"
def test_date_service_duration_calculation():
d1 = date(2026, 9, 10)
d2 = date(2026, 9, 15)
# gap between the dates: the end date is not counted
assert date_service.calculate_duration(d1, d2) == 5
assert date_service.calculate_duration(d1, d1) == 0
assert date_service.calculate_duration(None, d2) == 0
# inverted range is not a duration
assert date_service.calculate_duration(d2, d1) == 0
def test_date_service_compute_end_date():
d1 = date(2026, 9, 10)
# 0 days means start and end are the same day
assert date_service.compute_end_date(d1, 0) == d1
# 5 days is the gap, so the end date is 5 days later
assert date_service.compute_end_date(d1, 5) == date(2026, 9, 15)
def test_duration_and_end_date_are_inverses():
"""The pair must round-trip, otherwise saved values drift on reload."""
d1 = date(2026, 9, 10)
for gap in (0, 1, 4, 5, 30):
end = date_service.compute_end_date(d1, gap)
assert date_service.calculate_duration(d1, end) == gap
+155
View File
@@ -0,0 +1,155 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db import get_db
from app.main import app
from app.models import Base, PMUser
from app.security import create_token, hash_password
from app.utils.date_service import date_service
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.asyncio
async def test_automated_lifecycle_dates():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
admin = PMUser(
username="admin",
password_hash=hash_password("admin123"),
display_name="مدیر ارشد",
role="project_manager",
)
session.add(admin)
await session.commit()
admin_id = admin.id
token = create_token(admin_id)
headers = {"Authorization": f"Bearer {token}"}
async def override_get_db():
async with async_session() as s:
yield s
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
# 1. Create a project
p_res = await client.post(
"/api/v1/projects",
json={"name": "پروژه بیمارستان البرز"},
headers=headers,
)
assert p_res.status_code == 200
project_id = p_res.json()["data"]["id"]
assert p_res.json()["data"]["planned_start"] is None
assert p_res.json()["data"]["planned_end"] is None
# 2. Create Task 1 (earliest: 2026-10-01 to 2026-10-10)
t1_res = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "گودبرداری و خاکبرداری",
"planned_start": "2026-10-01",
"planned_end": "2026-10-10",
},
headers=headers,
)
assert t1_res.status_code == 200
t1_id = t1_res.json()["data"]["id"]
assert t1_res.json()["data"]["actual_start"] is None
assert t1_res.json()["data"]["actual_end"] is None
# Verify project planned dates matched Task 1
p_get1 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get1.json()["data"]["planned_start"] == "2026-10-01"
assert p_get1.json()["data"]["planned_end"] == "2026-10-10"
assert p_get1.json()["data"]["actual_start"] is None
assert p_get1.json()["data"]["actual_end"] is None
# 3. Create Task 2 (later: 2026-10-11 to 2026-10-25)
t2_res = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "اجرای فونداسیون",
"planned_start": "2026-10-11",
"planned_end": "2026-10-25",
},
headers=headers,
)
assert t2_res.status_code == 200
t2_id = t2_res.json()["data"]["id"]
# Verify project planned dates span from Task 1 (first) to Task 2 (last)
p_get2 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get2.json()["data"]["planned_start"] == "2026-10-01"
assert p_get2.json()["data"]["planned_end"] == "2026-10-25"
# 4. Progress on Task 1 moves from 0 to 25%
today_iso = date_service.today().isoformat()
prog1_res = await client.patch(
f"/api/v1/tasks/{t1_id}/progress",
json={"percentage": 25},
headers=headers,
)
assert prog1_res.status_code == 200
assert prog1_res.json()["data"]["actual_start"] == today_iso
assert prog1_res.json()["data"]["actual_end"] is None
# Check project dates: project.actual_start is now set, actual_end is None
p_get3 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get3.json()["data"]["actual_start"] == today_iso
assert p_get3.json()["data"]["actual_end"] is None
# 5. Complete Task 1 (100%)
prog1_100 = await client.patch(
f"/api/v1/tasks/{t1_id}/progress",
json={"percentage": 100},
headers=headers,
)
assert prog1_100.status_code == 200
assert prog1_100.json()["data"]["actual_end"] == today_iso
# Project actual_end should still be None because Task 2 (the last task) hasn't finished
p_get4 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get4.json()["data"]["actual_end"] is None
# 6. Complete Task 2 (last task) (100%)
prog2_100 = await client.patch(
f"/api/v1/tasks/{t2_id}/progress",
json={"percentage": 100},
headers=headers,
)
assert prog2_100.status_code == 200
assert prog2_100.json()["data"]["actual_end"] == today_iso
# Now project actual_end should match Task 2's actual_end
p_get5 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get5.json()["data"]["actual_end"] == today_iso
assert p_get5.json()["data"]["progress"] == 100.0
# 7. Test reset to 0% on Task 2: actual_end and actual_start are cleared
prog2_0 = await client.patch(
f"/api/v1/tasks/{t2_id}/progress",
json={"percentage": 0},
headers=headers,
)
assert prog2_0.status_code == 200
assert prog2_0.json()["data"]["actual_start"] is None
assert prog2_0.json()["data"]["actual_end"] is None
p_get6 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get6.json()["data"]["actual_end"] is None
+200
View File
@@ -0,0 +1,200 @@
"""Integration tests for native Projects, Tasks hierarchy, and QC gating invariants."""
import uuid
from datetime import date
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
import pytest_asyncio
from app.models import (
Base,
PMUser,
Project,
ProjectMember,
ProjectStatus,
QCAnswer,
QCQuestionPhase,
Task,
TaskPriority,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
TaskStatus,
)
from app.qc_service import build_state, load_definitions
@pytest_asyncio.fixture
async def async_db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_project_and_members_creation(async_db: AsyncSession):
# 1. Create a user
user = PMUser(
username="manager1",
password_hash="fake",
display_name="مهندس مدیر",
role="project_manager",
)
async_db.add(user)
await async_db.commit()
# 2. Create a project
project = Project(
name="پروژه برج بهارستان",
description="ساخت مجتمع ۲۰ طبقه",
location="تهران",
project_manager="مهندس مدیر",
status=ProjectStatus.ACTIVE.value,
planned_start=date(2026, 9, 1),
planned_end=date(2027, 9, 1),
)
async_db.add(project)
await async_db.flush()
# 3. Add a project member
member = ProjectMember(
project_id=project.id,
user_id=user.id,
role="project_manager",
)
async_db.add(member)
await async_db.commit()
# Verify project
loaded = await async_db.scalar(select(Project).where(Project.id == project.id))
assert loaded is not None
assert loaded.name == "پروژه برج بهارستان"
assert loaded.status == "ACTIVE"
@pytest.mark.asyncio
async def test_hierarchical_tasks(async_db: AsyncSession):
# Setup project
project = Project(name="پروژه تست ساختار شکست")
async_db.add(project)
await async_db.commit()
# 1. Phase 1 (Root Task)
phase1 = Task(
project_id=project.id,
parent_id=None,
title="فاز ۱: اسکلت فلزی",
status=TaskStatus.TODO.value,
)
async_db.add(phase1)
await async_db.flush()
# 2. Activity (Subtask under Phase 1)
activity1 = Task(
project_id=project.id,
parent_id=phase1.id,
title="فعالیت ۱.۱: نصب ستون‌های طبقه اول",
status=TaskStatus.TODO.value,
)
async_db.add(activity1)
await async_db.flush()
# 3. Subtask under Activity
subtask1 = Task(
project_id=project.id,
parent_id=activity1.id,
title="زیرتسک ۱.۱.۱: بولت‌گذاری و تراز پایه",
status=TaskStatus.TODO.value,
)
async_db.add(subtask1)
await async_db.commit()
# Query hierarchy
root = await async_db.scalar(select(Task).where(Task.id == phase1.id))
assert root is not None
assert root.parent_id is None
leaf = await async_db.scalar(select(Task).where(Task.id == subtask1.id))
assert leaf is not None
assert leaf.parent_id == activity1.id
@pytest.mark.asyncio
async def test_qc_gating_invariants(async_db: AsyncSession):
project = Project(name="پروژه تست کیفیت")
async_db.add(project)
await async_db.commit()
task = Task(
project_id=project.id,
title="بتن‌ریزی فونداسیون",
status=TaskStatus.TODO.value,
)
async_db.add(task)
await async_db.flush()
# Create task QC definition with 1 pre-execution question and 1 execution question
qc_def = TaskQCDefinition(task_id=task.id, name="چک‌لیست بتن‌ریزی")
async_db.add(qc_def)
await async_db.flush()
q_pre = TaskQCQuestion(
definition_id=qc_def.id,
phase=QCQuestionPhase.pre,
text="آیا آرماتوربندی و قالب‌بندی بازرسی و تأیید شد؟",
required=True,
sort_order=1,
)
q_exec = TaskQCQuestion(
definition_id=qc_def.id,
phase=QCQuestionPhase.during,
text="آیا اسلامپ بتن در محدوده استاندارد است؟",
required=True,
sort_order=2,
)
async_db.add_all([q_pre, q_exec])
await async_db.commit()
task_id = task.id
# Invariant Rule 1 check: Before answering pre-QC, can_start must be FALSE
defs = await load_definitions(async_db, task_id)
state = build_state(defs)
assert state.can_start is False
assert state.can_complete is False
# Now pass the pre-QC question
res_pre = TaskQCResult(question_id=q_pre.id, question=q_pre, answer=QCAnswer.pass_)
async_db.add(res_pre)
await async_db.commit()
# Load fresh definitions in new query
defs = await load_definitions(async_db, task_id)
state = build_state(defs)
# can_start is now TRUE! Progress can become > 0
assert state.can_start is True
# But can_complete is still FALSE because execution question is not yet answered!
assert state.can_complete is False
# Now pass the execution question as well
res_exec = TaskQCResult(question_id=q_exec.id, question=q_exec, answer=QCAnswer.pass_)
async_db.add(res_exec)
await async_db.commit()
defs = await load_definitions(async_db, task_id)
state = build_state(defs)
# Both can_start and can_complete are now TRUE!
assert state.can_start is True
assert state.can_complete is True
+64
View File
@@ -0,0 +1,64 @@
import uuid
from datetime import date
from types import SimpleNamespace
import pytest
from app.scheduling_service import calculate_schedule, check_for_cycle
def test_cycle_detection():
id_a = uuid.uuid4()
id_b = uuid.uuid4()
id_c = uuid.uuid4()
edges = [
(id_a, id_b),
(id_b, id_c),
]
# Adding C -> A should detect cycle
assert check_for_cycle(edges, id_c, id_a) is True
# Adding A -> A (self-loop) should detect cycle
assert check_for_cycle(edges, id_a, id_a) is True
# Adding C -> D (new node) should not detect cycle
id_d = uuid.uuid4()
assert check_for_cycle(edges, id_c, id_d) is False
def test_cpm_forward_and_backward_pass():
id_a = uuid.uuid4()
id_b = uuid.uuid4()
id_c = uuid.uuid4()
t_a = SimpleNamespace(id=id_a, duration=3, planned_start=date(2026, 1, 1), parent_id=None, progress=0)
t_b = SimpleNamespace(id=id_b, duration=4, planned_start=None, parent_id=None, progress=0)
t_c = SimpleNamespace(id=id_c, duration=2, planned_start=None, parent_id=None, progress=0)
# A (3d) -[FS + 1 lag]-> B (4d) -[FS + 0 lag]-> C (2d)
rel_ab = SimpleNamespace(from_task_id=id_a, to_task_id=id_b, relation_type="FS", lag_days=1)
rel_bc = SimpleNamespace(from_task_id=id_b, to_task_id=id_c, relation_type="FS", lag_days=0)
res = calculate_schedule([t_a, t_b, t_c], [rel_ab, rel_bc], project_start=date(2026, 1, 1))
sched = res["tasks_schedule"]
# Task A: 2026-01-01, duration 3 is a gap -> ends 2026-01-04
assert sched[id_a]["planned_start"] == date(2026, 1, 1)
assert sched[id_a]["planned_end"] == date(2026, 1, 4)
# Task B starts right after A finishes (Jan 4) plus 1 lag day = Jan 5
assert sched[id_b]["planned_start"] == date(2026, 1, 5)
# B: duration 4 gap -> Jan 5 + 4 = Jan 9
assert sched[id_b]["planned_end"] == date(2026, 1, 9)
# Task C starts Jan 9, duration 2 gap -> ends Jan 11
assert sched[id_c]["planned_start"] == date(2026, 1, 9)
assert sched[id_c]["planned_end"] == date(2026, 1, 11)
# All three are on critical path
assert id_a in res["critical_path"]
assert id_b in res["critical_path"]
assert id_c in res["critical_path"]
+1
View File
@@ -0,0 +1 @@
"""PM backend tools (seed etc.)."""
+304
View File
@@ -0,0 +1,304 @@
"""Seed PM with the 8 standard construction QC templates.
Each template now carries three stages:
* pre — آمادگی شروع کار (readiness; a task cannot start until these pass)
* during — کنترل حین اجرا
* after — کنترل نهایی
Idempotent — run any time: python -m tools.seed
"""
import asyncio
import logging
from sqlalchemy import select
from app.db import SessionFactory, engine
from app.models import Base, QCQuestion, QCQuestionPhase, QCTemplate
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("pm.seed")
# (activity_key, name, pre checklist, during checklist, after checklist)
TEMPLATES: list[tuple[str, str, list[str], list[str], list[str]]] = [
(
"concrete",
"بتن‌ریزی",
[
"آرماتوربندی و قالب‌بندی تأیید شده است؟",
"سطح قالب تمیز و روغن‌کاری شده است؟",
"بتن سفارش‌شده و پمپ/ویبره هماهنگ شده است؟",
"دسترسی و مسیر تخلیه بتن آماده است؟",
],
[
"مشخصات بتن (عیار/روانی) مطابق طراحی است؟",
"ویبره بدون جداشدگی انجام شد؟",
"ضخامت و تراز حین اجرا کنترل شد؟",
],
[
"سطح یکنواخت و بدون جداشدگی است؟",
"ترک مویی یا ریزش لبه وجود ندارد؟",
"عمل‌آوری (کیورینگ) طبق برنامه انجام شد؟",
],
),
(
"plaster",
"گچ‌کاری",
[
"سطح آماده‌سازی (شستشو/چکش‌کاری) انجام شده؟",
"داربست/چهارپایه سالم آماده است؟",
"مصالح (گچ/پودر) و آب در دسترس است؟",
],
[
"ضخامت گچ طبق مشخصات است؟",
"شاقول و تراز رعایت شده؟",
"درزها و اتصالات کنترل شد؟",
],
[
"سطح یکنواخت و بدون موج است؟",
"ترک ندارد؟",
"گوشه‌ها و لبه‌ها سالم هستند؟",
],
),
(
"rebar",
"آرماتوربندی",
[
"نقشه‌های اجرایی و لیست برش آماده است؟",
"میلگرد مورد نیاز در کارگاه موجود است؟",
"سطح کارگاه و مسیر دسترسی آماده است؟",
],
[
"قطر و تعداد میلگرد طبق نقشه است؟",
"فاصله خاموت‌ها رعایت شده؟",
"پوشش بتن (کاور) مناسب است؟",
],
[
"اتصالات (بست/جوش/درزگیری) مطابق آیین‌نامه است؟",
"آرماتور اضافی و براده‌ها جمع شده؟",
"آماده بتن‌ریزی است؟",
],
),
(
"formwork",
"قالب‌بندی",
[
"نقشه قالب‌بندی و ابعاد مشخص است؟",
"قالب و شمع‌بندی سالم و کافی موجود است؟",
"روغن قالب آماده است؟",
],
[
"ابعاد و تراز طبق نقشه است؟",
"شمع‌بندی و مهاربندی کافی است؟",
"درزهای قالب آب‌بند است؟",
],
["قالب بدون تغییر شکل است؟", "سطح تماس مناسب بتن‌ریزی است؟"],
),
(
"blockwork",
"تیغه‌چینی",
[
"آجر/بلوک و ملات در پای کار موجود است؟",
"خط‌اندازی و نقشه چیدمان انجام شده؟",
"سطح زیرکار آماده و تمیز است؟",
],
[
"خط و تراز دیوار تنظیم شده؟",
"مقاومت ملات مناسب است؟",
"بندکشی و فاصله درزها صحیح است؟",
],
[
"سطح دیوار صاف و شاقول است؟",
"درزها پر و یکنواخت است؟",
"نازک‌کاری بعدی بدون اصلاح ممکن است؟",
],
),
(
"isogam",
"ایزوگام",
[
"سطح بام خشک و تمیز است؟",
"پرایمر و مشعل آماده است؟",
"هواشناسی برای اجرا مناسب است؟",
],
[
"پرایمر اجرا شده؟",
"همپوشانی نوارها حداقل ۱۰ سانتی‌متر است؟",
"حرارت شعله مناسب و بدون سوختگی است؟",
],
["چکه و تورفتگی ندارد؟", "لبه‌ها و اتصالات کاملاً چسبیده؟", "آزمون آب (باران مصنوعی) پاس شد؟"],
),
(
"tiling",
"کاشی‌کاری",
[
"زیرکار تراز، خشک و آماده است؟",
"کاشی/دوغاب/چسب در پای کار موجود است؟",
"نقشه چیدمان و بندبندی مشخص شده است؟",
],
[
"دوغاب و ملات مناسب است؟",
"بندبندی یکنواخت است؟",
"کاشی‌های شکسته کنار گذاشته شده؟",
],
["صدای توخالی ندارد؟", "تراز و پلمپ رعایت شده؟", "بندها تمیز و هم‌رنگ است؟"],
),
(
"mep",
"تأسیسات",
[
"نقشه‌های مکانیکال/الکتریکال آماده است؟",
"مقطع و اتصالات پیش‌بینی‌شده موجود است؟",
"مسیر و برش‌های مورد نیاز هماهنگ شده؟",
],
[
"مسیر لوله‌کشی طبق نقشه است؟",
"آزمون فشار حین اجرا انجام شد؟",
"ایزولاسیون لوله‌ها اجرا شده؟",
],
["آزمون نهایی سیستم انجام شد؟", "برچسب‌گذاری و شناسایی کامل است؟", "تحویل به ناظر ثبت شد؟"],
),
(
"excavation",
"گودبرداری و سازه نگهبان",
[
"نقشه‌های مصوب سازه نگهبان و مجوزهای گودبرداری اخذ شده است؟",
"وضعیت ابنیه و تأسیسات همجوار و چاه‌های مجاور بررسی و صورتجلسه شده است؟",
"ایستگاه‌های پایش و مانیتورینگ تغییرشکل دیواره‌ها نصب شده است؟",
"ماشین‌آلات و پمپ‌های زهکشی و هدایت آب سطحی مستقر است؟",
],
[
"خاکبرداری مرحله‌ای طبق ترازهای مصوب طرح سازه نگهبان انجام می‌شود؟",
"المان‌های سازه نگهبان (سولجر، نیلینگ، استرات و مهارها) به‌موقع اجرا شدند؟",
"رفتارنگاری روزانه دیواره‌ها و ساختمان‌های مجاور ثبت و کنترل می‌شود؟",
],
[
"تراز نهایی کف گود و رقوم ارتفاعی فونداسیون کنترل و تأیید شد؟",
"دیواره‌ها و ترانشه‌های گود کاملاً پایدار تحویل شدند؟",
"صورتجلسه تحویل بستر پی به آزمایشگاه مکانیک خاک و دستگاه نظارت تنظیم شد؟",
],
),
(
"steel_structure",
"اسکلت فولادی و اتصالات جوشی/پیچی",
[
"نقشه‌های شاپ‌دراویینگ (Shop Drawings) و دستورکار اتصالات ابلاغ شده است؟",
"گواهی‌های فنی متریال فولادی، الکترودها و پیچ‌های پرمقاومت بررسی و تأیید شد؟",
"صلاحیت جوشکاران (کارت کد) و کالیبراسیون تجهیزات جوش کنترل گردید؟",
"آکس‌بندی، تراز بیس‌پلیت‌ها و استحکام انکربولت‌ها بررسی شد؟",
],
[
"رواداری شاقولی ستون‌ها و تراز پل‌ها و تیرها در حین برپایی کنترل شد؟",
"سایز، بعد، طول و توالی پاس‌های جوشکاری طبق WPS اجرا گردید؟",
"گشتاور پیچ‌های پرمقاومت اصطکاکی با ترکمتر کالیبره کنترل شد؟",
],
[
"نتایج آزمون‌های غیرمخرب جوش (VT/PT/UT/MT) مطابق ضوابط اخذ و تأیید شد؟",
"تمیزکاری سرباره جوش، پاشش‌ها و سندبلاست موضعی انجام گرفت؟",
"پوشش ضدزنگ و رنگ‌آمیزی حفاظتی طبق ضخامت مشخصات فنی اجرا شد؟",
],
),
(
"joist_slab",
"اجرای سقف تیرچه بلوک و دال بتنی",
[
"شمع‌بندی، پایه‌های اطمینان و مهاربندی زیر سقف با تراکم استاندارد اجرا شد؟",
"تیرچه‌ها سالم، بدون ترک‌خوردگی و با خیز منفی مناسب نصب شده‌اند؟",
"مسیرهای عبور لوله‌های تأسیسات برقی و مکانیکی جاگذاری و تثبیت شدند؟",
],
[
"فاصله تیرچه‌ها، جانمایی کلاف میانی (تای‌بیم) و میلگردهای ممان منفی کنترل شد؟",
"آرماتورهای حرارتی با پوشش کاور و همپوشانی مجاز نصب گردیدند؟",
"بلوک‌ها/یونولیت‌های سقفی سالم و کاملاً جذب بدون درز چیده شدند؟",
],
[
"ضخامت بتن دال روی تیرچه‌ها در تمام نقاط کنترل و تأیید شد؟",
"خیز سقف پس از بتن‌ریزی و برداشتن شمع‌ها در حد مجاز آیین‌نامه است؟",
"کیورینگ و مرطوب نگه‌داشتن دال بتنی طبق برنامه زمان‌بندی انجام گرفت؟",
],
),
(
"stone_facade",
"سنگ‌کاری نما و کف",
[
"شاسی‌کشی فلزی و زیرسازی نما طبق جزئیات اجرایی تراز و شاقول است؟",
"سنگ‌ها سالم، هم‌رنگ و بدون رگه‌های سست و ترک انتخاب شده‌اند؟",
"کلیه سنگ‌های نما شیارزنی شده و دارای اسکوپ سیمی یا مکانیکی مطمئن هستند؟",
],
[
"ملات ماسه سیمان پشت سنگ پرعیار و به‌صورت یکنواخت بدون پوکی پر می‌شود؟",
"شاقول، تراز و ریسمان‌کشی بندهای افقی و قائم به طور مداوم کنترل می‌گردد؟",
"درزهای انبساط حرارتی نما در فواصل پیش‌بینی‌شده اجرا شد؟",
],
[
"سطح سنگ‌کاری شاقول، گونیا، تخت و بدون پله‌شدن لبه‌هاست؟",
"بندکشی توپر، صاف و با ملات ضدرطوبت همرنگ اجرا شد؟",
"تست چسبندگی سنگ‌ها و شستشوی نهایی سطح با موفقیت انجام گرفت؟",
],
),
(
"doors_windows",
"نصب درب و پنجره‌های دوجداره",
[
"ابعاد و شاقولی ساب‌فریم‌ها (فریم‌های انتظار) کنترل و رنگ ضدزنگ خورده است؟",
"پروفیل‌ها، یراق‌آلات و شیشه‌های دوجداره سالم و استاندارد تخلیه شده‌اند؟",
],
[
"شاقول و تراز بودن فریم‌ها در سه محور و فواصل پیچ‌های بولت کنترل شد؟",
"تزریق فوم پلی‌اورتان برای هوابندی و عایق صوتی دور فریم انجام گرفت؟",
"درزگیری محیط خارجی پنجره با چسب سیلیکون خنثی آنتی‌یووی (UV) اجرا شد؟",
],
[
"رگلاژ یراق‌آلات و روانی بازشوها کنترل و قفل‌ها به درستی عمل می‌کنند؟",
"تست عدم نفوذ آب باران و باد انجام و تأیید گردید؟",
"پوشش‌های محافظ و چسب‌های سطحی پاکسازی و فریم‌ها تمیز شدند؟",
],
),
(
"painting",
"نقاشی و رنگ‌آمیزی ساختمان",
[
"سطح گچ‌کاری کاملاً خشک، بدون رطوبت، پوکی یا شوره است؟",
"روغن زیرکار (پرایمر نفوذگر) متناسب با نوع رنگ زیرین اعمال گردیده است؟",
"کاورکشی و حفاظت از کف، پنجره‌ها و چهارچوب‌ها انجام شده است؟",
],
[
"بتونه‌کاری سراسری، لکه‌گیری و سنباده‌زنی یکدست با نور موضعی انجام شد؟",
"تعداد لایه‌های رنگ و فاصله زمانی کافی برای خشک‌شدن لایه‌ها رعایت گردید؟",
],
[
"سطح نهایی صاف، یکدست و بدون اثر قلم‌مو، شره و سایه‌روشن است؟",
"تطابق کد رنگ اجرا شده با شید رنگ مصوب کارفرما تأیید شد؟",
"تمیزکاری نهایی محیط از پاشش ذرات رنگ انجام گردید؟",
],
),
]
async def seed() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionFactory() as db:
for key, name, pre, during, after in TEMPLATES:
existing = await db.scalar(select(QCTemplate).where(QCTemplate.name == name))
if existing is not None:
continue
template = QCTemplate(name=name, activity_type=key, description=f"چک‌لیست کنترل کیفیت {name}")
db.add(template)
await db.flush()
for phase, items in (
(QCQuestionPhase.pre, pre),
(QCQuestionPhase.during, during),
(QCQuestionPhase.after, after),
):
for index, text in enumerate(items):
db.add(
QCQuestion(template_id=template.id, phase=phase, text=text, sort_order=index)
)
log.info("QC template seeded: %s (pre=%d during=%d after=%d)", name, len(pre), len(during), len(after))
await db.commit()
log.info("seed done")
if __name__ == "__main__":
asyncio.run(seed())
+115
View File
@@ -0,0 +1,115 @@
"""Bootstrap the PM→ERP service account (one-off, idempotent).
1. Creates Casdoor user `pm-service` (org `inventory`) via the Casdoor admin API.
2. Performs a password grant so ERP auto-provisions the users row.
3. Promotes that row to role `admin` (read-only across all projects —
the ERP inventory router lets admin read without a project scope).
Usage (from the pm-api container):
PM_ERP_SERVICE_PASSWORD=... python -m tools.seed_erp_service
"""
import asyncio
import base64
import json
import logging
import os
import urllib.request
from app.config import settings
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("pm.seed_erp")
ORG = settings.ERP_CASDOOR_ORGANIZATION
USERNAME = settings.ERP_SERVICE_USERNAME
PASSWORD = settings.ERP_SERVICE_PASSWORD or os.environ.get("PM_ERP_SERVICE_PASSWORD", "pm-service-2026")
EMAIL = f"{USERNAME}@sample.ir"
ERP_DSN = "postgresql://postgres:postgres@procurement-postgres:5432/procurement"
def _post(url: str, body: dict, headers: dict | None = None) -> dict:
req = urllib.request.Request(
url,
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=15)
return json.loads(resp.read())
def casdoor_admin_cookie() -> str:
resp = urllib.request.urlopen(urllib.request.Request(
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login",
data=json.dumps({
"type": "login", "organization": "built-in",
"username": "admin", "password": "123", "application": "app-built-in",
}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
), timeout=15)
data = json.loads(resp.read())
if data.get("status") != "ok":
raise SystemExit(f"casdoor admin login failed: {data}")
return (resp.headers.get("Set-Cookie") or "").split(";")[0]
async def main() -> None:
# 1) casdoor user (idempotent — casdoor rejects duplicates, that's fine)
cookie = casdoor_admin_cookie()
for path, body in [
("/api/add-user", {
"owner": ORG, "name": USERNAME, "displayName": "PM Service",
"firstName": "PM", "lastName": "Service", "email": EMAIL,
"password": PASSWORD, "passwordType": "plain", "type": "normal-user",
}),
(f"/api/update-user?id={ORG}/{USERNAME}&columns=password_type", {"passwordType": "plain"}),
]:
try:
result = _post(f"{settings.ERP_CASDOOR_ENDPOINT}{path}", body, {"Cookie": cookie})
log.info("%s → %s", path.split("?")[0], result.get("msg", result.get("status")))
except Exception as exc: # noqa: BLE001
log.warning("%s failed: %s", path.split("?")[0], exc)
# 2) password grant (PM never verifies this token — it just forwards it)
grant = _post(f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token", {
"grant_type": "password",
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
"username": USERNAME,
"password": PASSWORD,
})
token = grant.get("access_token")
if not token:
raise SystemExit(f"password grant failed: {grant}")
claims = json.loads(base64.urlsafe_b64decode(token.split(".")[1] + "=" * (-len(token.split(".")[1]) % 4)))
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
log.info("grant ok, casdoor_id=%s", casdoor_id[:8])
# trigger ERP auto-provisioning
me_req = urllib.request.Request(
f"{settings.ERP_BASE_URL}/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
)
try:
urllib.request.urlopen(me_req, timeout=15).read()
log.info("ERP provisioning call ok")
except Exception as exc: # noqa: BLE001
log.warning("provisioning call failed (row may already exist): %s", exc)
# 3) promote in ERP DB (direct SQL — this service account is PM infrastructure)
import asyncpg
conn = await asyncpg.connect(ERP_DSN)
try:
updated = await conn.execute(
"UPDATE users SET role='admin' WHERE casdoor_id=$1", casdoor_id
)
log.info("ERP role promotion: %s", updated)
finally:
await conn.close()
log.info("service account ready: %s / ***", USERNAME)
if __name__ == "__main__":
asyncio.run(main())
+89
View File
@@ -0,0 +1,89 @@
# 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
dockerfile: Dockerfile
container_name: pm-api
environment:
PM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@procurement-postgres:5432/procurement
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
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_default
restart: unless-stopped
pm-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: pm-frontend
ports:
- "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
# اولویت بالاتر از روتر 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_default:
external: true
traefik_network:
external: true
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
*.log
Dockerfile
.dockerignore
+17
View File
@@ -0,0 +1,17 @@
# ---- مرحله ساخت ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- مرحله اجرا ----
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f8fafc" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230284c7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='8' y='2' width='8' height='4' rx='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3Cpath d='m9 14 2 2 4-4'/%3E%3C/svg%3E"
/>
<title>PM — مدیریت پروژه</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400..800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 15m;
# PM روی همان دامنه ERP زیر پیشوند /pm سرو می‌شود (پنل «مدیریت پروژه»).
# پروکسی API به بک‌اند PM — هم زیر /pm/api (ادغام با ERP) هم /api (دسترسی مستقیم)
location /pm/api/ {
proxy_pass http://pm-api:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://pm-api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA زیر /pm/ — نقطه ورود پنل «مدیریت پروژه» روی دامنه ERP
location /pm/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /pm/index.html;
}
location = /pm {
return 301 /pm/;
}
location = /pm/index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
alias /usr/share/nginx/html/index.html;
}
# SPA fallback (دسترسی مستقیم به فرانت PM بدون پیشوند)
location / {
try_files $uri $uri/ /index.html;
}
# index.html همیشه بدون کش
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# فایل‌های استاتیک هش‌دار با کش بلندمدت
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}
+2650
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "pm-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"sonner": "^1.7.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { getMe } from "./lib/api";
import { Perm, hasPermission } from "./lib/roles";
import { sessionNeedsRefresh, useAuthStore } from "./store/auth";
import { PhoneShell, TabBar } from "./components/ui";
import LoginPage from "./pages/LoginPage";
import TodayPage from "./pages/TodayPage";
import ProjectsPage from "./pages/ProjectsPage";
import ProjectDetailPage from "./pages/ProjectDetailPage";
import TaskDetailPage from "./pages/TaskDetailPage";
import TaskQcDefinePage from "./pages/TaskQcDefinePage";
import TaskQcExecutePage from "./pages/TaskQcExecutePage";
import QualityPage from "./pages/QualityPage";
import SettingsPage from "./pages/SettingsPage";
/** ستون موبایلی برنامه — روی همه دستگاه‌ها به شکل یک اپ موبایل رندر می‌شود */
function AppLayout() {
return (
<PhoneShell>
<Outlet />
<TabBar />
</PhoneShell>
);
}
function LoadingScreen() {
return (
<PhoneShell>
<div className="flex min-h-dvh items-center justify-center text-xs text-slate-400">
در حال بارگذاری...
</div>
</PhoneShell>
);
}
/**
* نگهبان مسیرها: توکن لازم است و کاربر باید اطلاعات نقش/مجوز داشته باشد.
* نشست‌های ذخیره‌شده پیش از RBAC یک‌بار از سرور تازه می‌شوند.
*/
function RequireAuth() {
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const queryClient = useQueryClient();
useEffect(() => {
if (!token) return;
let cancelled = false;
void getMe()
.then((fresh) => {
if (!cancelled) setUser(fresh);
})
.catch((err: { status?: number }) => {
if (err?.status === 403) {
if (window.top !== window.self) {
window.top!.location.assign("/");
} else {
window.location.assign("/");
}
}
});
return () => {
cancelled = true;
};
}, [token, setUser]);
// با عوض شدن حساب، داده‌های نشست قبلی نباید در کش بمانند.
//
// نکتهٔ مهم: این افکت نباید در mount اولیه اجرا شود. اگر با اولین رندر
// (وقتی identity تازه ساخته می‌شود) کش پاک شود، کوئری‌ای که صفحهٔ زیرین
// همین حالا شروع کرده لغو می‌شود و برای همیشه در حالت «در حال بارگذاری»
// می‌ماند. پس مقدار قبلی را نگه می‌داریم و فقط با تغییر واقعی پاک می‌کنیم.
const identity = `${user?.username ?? ""}:${user?.role ?? ""}`;
const lastIdentity = useRef<string | null>(null);
useEffect(() => {
const previous = lastIdentity.current;
lastIdentity.current = identity;
if (previous !== null && previous !== identity) {
queryClient.clear();
}
}, [identity, queryClient]);
if (!token) {
// تک‌احراز هویت با ERP/Casdoor: داخل پوسته ERP فرم ورود PM نداریم —
// سطح کل صفحه به ورود یکپارچه برمی‌گردد. مستقل (8083) همان ورود PM.
if (window.top !== window.self) {
window.top!.location.assign("/login");
return <LoadingScreen />;
}
return <Navigate to="/login" replace />;
}
if (sessionNeedsRefresh(user)) return <LoadingScreen />;
return <AppLayout />;
}
/** مسیرهایی که فقط با مجوز مشخص باز می‌شوند (سرور هم مستقل بررسی می‌کند) */
function RequirePermission({ permission, children }: { permission: string; children: ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!hasPermission(user, permission)) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<TodayPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:id" element={<ProjectDetailPage />} />
<Route path="/tasks/:id" element={<TaskDetailPage />} />
<Route
path="/tasks/:id/qc/define"
element={
<RequirePermission permission={Perm.qcDefine}>
<TaskQcDefinePage />
</RequirePermission>
}
/>
<Route path="/tasks/:id/qc/execute" element={<TaskQcExecutePage />} />
<Route
path="/quality"
element={
<RequirePermission permission={Perm.qcDefine}>
<QualityPage />
</RequirePermission>
}
/>
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-slate-50 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle size={26} />
</div>
<div>
<p className="text-base font-bold text-slate-800">خطای غیرمنتظره</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
مشکلی در نمایش برنامه پیش آمد. لطفا دوباره تلاش کنید.
</p>
{this.state.error && (
<div className="mt-3 max-w-sm rounded-xl border border-red-200 bg-red-50 p-3 text-left" dir="ltr">
<p className="text-[11px] font-mono font-bold text-red-700">
{this.state.error.name}: {this.state.error.message}
</p>
</div>
)}
</div>
<button
type="button"
onClick={() => this.setState({ error: null })}
className="h-11 rounded-xl bg-sky-600 px-6 text-sm font-semibold text-white active:bg-sky-700"
>
تلاش مجدد
</button>
</div>
);
}
return this.props.children;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,243 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
daysInMonth,
gregorianToJalali,
isoToJalaliStr,
jalaliStrToIso,
jalaliToGregorian,
MONTHS,
toFa,
WEEKDAYS,
} from "../lib/jalali";
import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps {
/** مقدار ISO (میلادی) — ممکن است زمان داشته باشد */
value: string | null;
onChange: (iso: string | null) => void;
placeholder?: string;
className?: string;
}
function todayJalali(): [number, number, number] {
const now = new Date();
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
}
function isoToJalali(iso: string | null): [number, number, number] | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
/** ISO با نیمه‌شب محلی — فیلدهای تاریخ‌محور PM فقط روز را حمل می‌کنند */
function buildIso(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd, 0, 0, 0).toISOString();
}
/** تاریخ انتخاب‌شده بر مبنای تقویم محلی (نه UTC) برای مقایسه با سلول‌های تقویم */
function localDateKey(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** تقویم شمسی — ورودی/خروجی ISO میلادی */
export function JalaliDatePicker({ value, onChange, placeholder, className = "" }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const [text, setText] = useState<string>(() => isoToJalaliStr(value));
// مقداری که خودمان تولید کرده‌ایم — تا وقتی والد از بیرون مقدار را عوض کند، متن همگام شود
const emittedRef = useRef<string | null>(value);
useEffect(() => {
if (value === emittedRef.current) return;
emittedRef.current = value;
setText(isoToJalaliStr(value));
const j = isoToJalali(value);
if (j) setCurJ(j);
}, [value]);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const selectedKey = localDateKey(value);
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 dim = daysInMonth(jy, jm);
const today = new Date();
const todayKey = `${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 });
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 === todayKey });
}
return cells;
}, [curJ]);
const nav = (dir: "prev-month" | "next-month" | "prev-year" | "next-year") => {
setCurJ(([jy, jm]) => {
let ny = jy;
let nm = jm;
if (dir === "next-month") nm += 1;
if (dir === "prev-month") nm -= 1;
if (dir === "next-year") ny += 1;
if (dir === "prev-year") ny -= 1;
if (nm < 1) {
nm = 12;
ny -= 1;
}
if (nm > 12) {
nm = 1;
ny += 1;
}
return [ny, nm, 1];
});
};
const apply = (jy: number, jm: number, jd: number) => {
const iso = buildIso(jy, jm, jd);
emittedRef.current = iso;
onChange(iso);
setText(isoToJalaliStr(iso));
setCurJ([jy, jm, jd]);
setOpen(false);
};
const pickToday = () => {
const [jy, jm, jd] = todayJalali();
apply(jy, jm, jd);
};
const clear = () => {
emittedRef.current = null;
onChange(null);
setText("");
setOpen(false);
};
const onInputChange = (v: string) => {
setText(v);
const iso = jalaliStrToIso(v);
if (iso) {
emittedRef.current = iso;
onChange(iso);
const j = isoToJalali(iso);
if (j) setCurJ(j);
}
};
const cellBase = "flex h-8 items-center justify-center rounded-lg text-[12px] transition-colors";
return (
<div ref={ref} className={`relative ${className}`}>
<input
type="text"
dir="ltr"
inputMode="numeric"
autoComplete="off"
value={text}
placeholder={placeholder ?? "۱۴۰۵/۰۶/۲۳"}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onChange={(e) => onInputChange(e.target.value)}
className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-left text-sm text-slate-800 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
{open && (
<div className="absolute start-0 top-full z-30 mt-1 w-[264px] max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg">
<div className="mb-1 flex items-center justify-between gap-0.5">
<button
type="button"
aria-label="سال قبل"
onClick={() => nav("prev-year")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-sm text-slate-500 active:bg-slate-100"
>
«
</button>
<button
type="button"
aria-label="ماه قبل"
onClick={() => nav("prev-month")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-base text-slate-500 active:bg-slate-100"
>
‹
</button>
<span className="text-[13px] font-bold text-slate-700">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span>
<button
type="button"
aria-label="ماه بعد"
onClick={() => nav("next-month")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-base text-slate-500 active:bg-slate-100"
>
›
</button>
<button
type="button"
aria-label="سال بعد"
onClick={() => nav("next-year")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-sm text-slate-500 active:bg-slate-100"
>
»
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-slate-400">
{w}
</div>
))}
{grid.map((cell, i) =>
cell.d === 0 ? (
<div key={`empty-${i}`} />
) : (
<button
key={cell.iso}
type="button"
onClick={() => apply(curJ[0], curJ[1], cell.d)}
className={`${cellBase} ${
cell.iso === selectedKey
? "bg-sky-600 font-bold text-white"
: cell.isToday
? "bg-sky-50 font-bold text-sky-700 active:bg-sky-100"
: "text-slate-600 active:bg-slate-100"
}`}
>
{toFa(cell.d)}
</button>
),
)}
</div>
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2">
<button
type="button"
onClick={pickToday}
className="rounded-lg px-2 py-1 text-[11.5px] font-semibold text-sky-600 active:bg-sky-50"
>
امروز
</button>
<button
type="button"
onClick={clear}
className="rounded-lg px-2 py-1 text-[11.5px] font-medium text-slate-500 active:bg-slate-100"
>
پاک کردن
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,194 @@
import { useState } from "react";
import { Plus, Trash2, Layers, X } from "lucide-react";
import type { HumanResourceItem, MaterialItem } from "../lib/api";
interface ResourceSectionInputsProps {
humanResources?: HumanResourceItem[];
setHumanResources?: (hrs: HumanResourceItem[]) => void;
materials: MaterialItem[];
setMaterials: (mats: MaterialItem[]) => void;
}
function TagInput({
tags,
onChange,
placeholder = "افزودن مشخصه...",
}: {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
const [val, setVal] = useState("");
const addTag = () => {
const trimmed = val.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
setVal("");
}
};
const removeTag = (index: number) => {
onChange(tags.filter((_, i) => i !== index));
};
return (
<div className="space-y-1.5">
<div className="flex gap-1">
<input
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
placeholder={placeholder}
className="h-8 flex-1 rounded-lg border border-slate-300 bg-white px-2.5 text-xs text-slate-800 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-200"
/>
<button
type="button"
onClick={addTag}
className="inline-flex h-8 items-center justify-center rounded-lg bg-slate-100 px-2.5 text-xs font-semibold text-slate-700 hover:bg-slate-200 transition-colors"
>
<Plus size={14} />
</button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 border border-slate-200"
>
{tag}
<button
type="button"
onClick={() => removeTag(i)}
className="text-slate-400 hover:text-rose-600 transition-colors"
aria-label="حذف مشخصه"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export function ResourceSectionInputs({
materials,
setMaterials,
}: ResourceSectionInputsProps) {
// --- Materials handlers ---
const addMaterial = () => {
setMaterials([...materials, { name: "", unit: "", specifications: [], count: 1 }]);
};
const updateMaterial = (index: number, field: keyof MaterialItem, value: any) => {
const next = [...materials];
next[index] = { ...next[index], [field]: value };
setMaterials(next);
};
const removeMaterial = (index: number) => {
setMaterials(materials.filter((_, i) => i !== index));
};
return (
<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">
<Layers size={14} className="text-amber-600" />
<span>مصالح و تجهیزات وظیفه</span>
</span>
{materials.length > 0 && (
<span className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-[10px] font-bold">
{materials.length} قلم
</span>
)}
</div>
{/* --- Materials & Equipment Content --- */}
<div className="space-y-2.5">
{materials.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400">
هیچ مصالح یا تجهیزاتی تعریف نشده است.
</p>
) : (
materials.map((m, i) => (
<div
key={i}
className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm space-y-2"
>
<div className="flex items-start gap-2">
<div className="flex-1">
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">نام مصالح، کالا یا تجهیزات</label>
<input
value={m.name}
onChange={(e) => updateMaterial(i, "name", e.target.value)}
placeholder="مثلاً: سیمان تیپ ۲، میلگرد ۱۶، پمپ بتن، تاور کرین"
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-amber-500 focus:outline-none"
/>
</div>
<div className="w-24">
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">واحد سنجش</label>
<input
value={m.unit || ""}
onChange={(e) => updateMaterial(i, "unit", e.target.value)}
placeholder="تن، متر، دستگاه، شاخه"
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-amber-500 focus:outline-none"
/>
</div>
<div className="w-20">
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">مقدار</label>
<input
type="number"
step="any"
min={0}
value={m.count}
onChange={(e) => updateMaterial(i, "count", Math.max(0, parseFloat(e.target.value) || 0))}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-amber-500 focus:outline-none"
/>
</div>
<button
type="button"
onClick={() => removeMaterial(i)}
className="mt-3.5 flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-colors"
title="حذف این ردیف"
>
<Trash2 size={15} />
</button>
</div>
<div>
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">مشخصه‌ها، استانداردها و مدل</label>
<TagInput
tags={m.specifications || []}
onChange={(tags) => updateMaterial(i, "specifications", tags)}
placeholder="مثال: استاندارد A3، عیار ۳۵۰، بوم ۳۶ متری (Enter بزنید)"
/>
</div>
</div>
))
)}
<button
type="button"
onClick={addMaterial}
className="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-amber-300 bg-amber-50/60 text-xs font-semibold text-amber-800 hover:bg-amber-100/70 transition-colors"
>
<Plus size={14} />
<span>افزودن مصالح و تجهیزات</span>
</button>
</div>
</div>
);
}
+736
View File
@@ -0,0 +1,736 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Link2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
createTaskRelation,
deleteTask,
deleteTaskRelation,
getProjectRelations,
updateTask,
updateTaskProgress,
} from "../lib/api";
import type {
HumanResourceItem,
MaterialItem,
Task,
TaskDetail,
UpdateTaskPayload,
} from "../lib/api";
import { Modal, btnPrimary, btnSecondary, inputCls } from "./ui";
import { JalaliDatePicker } from "./JalaliDatePicker";
import { ResourceSectionInputs } from "./ResourceSectionInputs";
import { isoToApiDate } from "../lib/jalali";
import { reconcileDates } from "../lib/dates";
import { formatDate } from "../lib/format";
interface TaskEditModalProps {
open: boolean;
task: Task | TaskDetail | null;
allTasks?: Task[];
projectId: string | number;
onClose: () => void;
onDeleted?: () => void;
onUpdated?: () => void;
}
function getDescendantIds(allTasks: Task[], targetTaskId: string): Set<string> {
const descendants = new Set<string>();
const childrenMap = new Map<string, string[]>();
for (const t of allTasks) {
const pId = t.parent_id
? String(t.parent_id)
: t.parent_op_wp_id != null
? String(t.parent_op_wp_id)
: null;
if (pId) {
const list = childrenMap.get(pId) || [];
list.push(String(t.id || t.op_wp_id));
childrenMap.set(pId, list);
}
}
const stack = [...(childrenMap.get(targetTaskId) || [])];
while (stack.length > 0) {
const current = stack.pop()!;
if (!descendants.has(current)) {
descendants.add(current);
const kids = childrenMap.get(current) || [];
for (const k of kids) stack.push(k);
}
}
return descendants;
}
export function TaskEditModal({
open,
task,
allTasks = [],
projectId,
onClose,
onDeleted,
onUpdated,
}: TaskEditModalProps) {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
const [status, setStatus] = useState("TODO");
const [priority, setPriority] = useState("normal");
const [plannedStart, setPlannedStart] = useState<string | null>(null);
const [plannedEnd, setPlannedEnd] = useState<string | null>(null);
const [durationDays, setDurationDays] = useState<number | "">("");
const [progress, setProgress] = useState<number | "">("");
const [humanResources, setHumanResources] = useState<HumanResourceItem[]>([]);
const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [errorMsg, setErrorMsg] = useState("");
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
useEffect(() => {
if (open && task) {
setTitle(task.title || task.subject || "");
setDescription((task as any).description || "");
const pId = task.parent_id
? String(task.parent_id)
: task.parent_op_wp_id != null
? String(task.parent_op_wp_id)
: "";
setParentId(pId);
setStatus(task.status || "TODO");
setPriority(task.priority || "normal");
setPlannedStart(task.planned_start || task.start_date || null);
setPlannedEnd(task.planned_end || task.due_date || null);
setDurationDays(task.duration || task.duration_days || "");
setProgress(task.progress ?? task.percentage_done ?? 0);
setHumanResources((task as any).human_resources || []);
setMaterials((task as any).materials || []);
setErrorMsg("");
setShowConfirmDelete(false);
}
}, [open, task]);
const taskId = task ? String(task.id || task.op_wp_id) : "";
// لیست والدهای مجاز (حذف خود تسک و تمام زیرتسک‌های آن برای جلوگیری از حلقه)
const validParents = useMemo(() => {
if (!taskId) return allTasks;
const descendantIds = getDescendantIds(allTasks, taskId);
return allTasks.filter((t) => {
const tKey = String(t.id || t.op_wp_id);
return tKey !== taskId && !descendantIds.has(tKey);
});
}, [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),
enabled: open && Boolean(projectId),
});
const projectRelations = relationsQuery.data || [];
const currentTaskRelations = useMemo(() => {
return projectRelations.filter((r) => r.from_task_id === taskId || r.to_task_id === taskId);
}, [projectRelations, taskId]);
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [relLag, setRelLag] = useState<number>(0);
const addRelationMutation = useMutation({
mutationFn: (payload: {
from_task_id: string;
to_task_id: string;
relation_type: "FS" | "SS" | "FF" | "SF";
lag_days: number;
}) => createTaskRelation(projectId, payload),
onSuccess: () => {
toast.success("رابطه وابستگی با موفقیت ایجاد شد");
setRelTargetId("");
setRelLag(0);
void queryClient.invalidateQueries({ queryKey: ["project-relations", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)");
},
});
const deleteRelationMutation = useMutation({
mutationFn: (relationId: string) => deleteTaskRelation(projectId, relationId),
onSuccess: () => {
toast.success("رابطه وابستگی با موفقیت حذف شد");
void queryClient.invalidateQueries({ queryKey: ["project-relations", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در حذف وابستگی");
},
});
/** 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);
}
},
onSuccess: () => {
toast.success("اطلاعات وظیفه با موفقیت به‌روزرسانی شد");
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
onClose();
onUpdated?.();
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ویرایش وظیفه");
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteTask(task!.id || task!.op_wp_id),
onSuccess: () => {
toast.success("وظیفه با موفقیت حذف شد");
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
onClose();
onDeleted?.();
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در حذف وظیفه");
setShowConfirmDelete(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!task) return;
if (!title.trim()) {
setErrorMsg("عنوان وظیفه الزامی است");
return;
}
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
}
if (!endIso) {
setErrorMsg("وارد کردن تاریخ پایان وظیفه الزامی است");
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
return;
}
// ۲. محدودیت نسبت به والد انتخابی
if (parentId) {
const selectedParent = allTasks.find(
(t) => String(t.id || t.op_wp_id) === String(parentId)
);
if (selectedParent) {
const pStart = selectedParent.planned_start || selectedParent.start_date;
const pEnd = selectedParent.planned_end || selectedParent.due_date;
if (pStart && startIso && startIso < pStart) {
setErrorMsg(
`تاریخ شروع وظیفه فرزند نمی‌تواند قبل از تاریخ شروع وظیفه والد (${(selectedParent as any).planned_start_jalali || pStart}) باشد`
);
return;
}
if (pEnd && endIso && endIso > pEnd) {
setErrorMsg(
`تاریخ پایان وظیفه فرزند نمی‌تواند بعد از تاریخ پایان وظیفه والد (${(selectedParent as any).planned_end_jalali || pEnd}) باشد`
);
return;
}
if (
typeof selectedParent.duration === "number" &&
typeof durationDays === "number" &&
durationDays > selectedParent.duration
) {
setErrorMsg(
`مدت زمان وظیفه والد (${selectedParent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند (${durationDays} روز) باشد`
);
return;
}
}
}
// ۳. محدودیت نسبت به فرزندان موجود
const children = allTasks.filter((t) => {
const p = t.parent_id
? String(t.parent_id)
: t.parent_op_wp_id != null
? String(t.parent_op_wp_id)
: null;
return p === taskId;
});
for (const c of children) {
const cStart = c.planned_start || c.start_date;
const cEnd = c.planned_end || c.due_date;
const cTitle = c.title || c.subject || "فرزند";
if (cStart && startIso && startIso > cStart) {
setErrorMsg(
`تاریخ شروع وظیفه والد نمی‌تواند بعد از تاریخ شروع وظیفه فرزند «${cTitle}» باشد`
);
return;
}
if (cEnd && endIso && endIso < cEnd) {
setErrorMsg(
`تاریخ پایان وظیفه والد نمی‌تواند قبل از تاریخ پایان وظیفه فرزند «${cTitle}» باشد`
);
return;
}
if (
typeof durationDays === "number" &&
typeof c.duration === "number" &&
durationDays < c.duration
) {
setErrorMsg(
`مدت زمان وظیفه والد (${durationDays} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «${cTitle}» (${c.duration} روز) باشد`
);
return;
}
}
updateMutation.mutate({
title: title.trim(),
description: description.trim(),
parent_id: parentId.trim() ? parentId.trim() : "none",
status,
priority,
planned_start: startIso,
planned_end: endIso,
duration_days: typeof durationDays === "number" ? durationDays : undefined,
human_resources: humanResources.filter((h) => h.role.trim()),
materials: materials.filter((m) => m.name.trim()),
});
};
if (!task) return null;
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 leading-5">
{errorMsg}
</div>
)}
{showConfirmDelete && (
<div className="rounded-xl bg-red-50 border border-red-200 p-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-red-700">
<AlertTriangle size={15} />
<span>تأیید حذف وظیفه</span>
</div>
<p className="text-[11px] text-red-600 leading-4">
آیا از حذف «{task.title || task.subject}» و تمام زیرتسک‌های مرتبط با آن اطمینان دارید؟
</p>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setShowConfirmDelete(false)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
className="inline-flex h-9 items-center justify-center rounded-xl bg-red-600 px-3 text-xs font-semibold text-white transition-colors hover:bg-red-700 active:bg-red-800"
>
{deleteMutation.isPending ? "در حال حذف..." : "بله، حذف شود"}
</button>
</div>
</div>
)}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
عنوان وظیفه *
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="عنوان وظیفه"
required
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
وظیفه والد (سطح بالاتر)
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className={inputCls}
>
<option value="">بدون والد (فعالیت اصلی / سطح ۱)</option>
{validParents.map((p) => {
const pKey = String(p.id || p.op_wp_id);
return (
<option key={pKey} value={pKey}>
{p.title || p.subject}
</option>
);
})}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
پیشرفت (٪)
</label>
<input
type="number"
min={0}
max={100}
dir="ltr"
value={progress}
onChange={(e) => {
const raw = e.target.value;
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
}}
placeholder="۰"
className={`${inputCls} text-left`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
اولویت
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className={inputCls}
>
<option value="low">پایین (درجه اهمیت = 1)</option>
<option value="normal">عادی (درجه اهمیت = 2)</option>
<option value="high">بالا (درجه اهمیت = 5)</option>
<option value="urgent">فوری (درجه اهمیت = 13)</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
تاریخ شروع برنامه‌ریزی‌شده <span className="text-rose-500">*</span>
</label>
<JalaliDatePicker
value={plannedStart}
onChange={(iso) => {
const next = reconcileDates(
{ start: iso, end: plannedEnd, days: durationDays },
"start",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="انتخاب تاریخ"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
تاریخ پایان برنامه‌ریزی‌شده <span className="text-rose-500">*</span>
</label>
<JalaliDatePicker
value={plannedEnd}
onChange={(iso) => {
const next = reconcileDates(
{ start: plannedStart, end: iso, days: durationDays },
"end",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="انتخاب تاریخ"
/>
</div>
</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">
<div>
<span className="text-slate-400">شروع واقعی: </span>
<span className="font-medium text-slate-700">
{(task as any)?.actual_start_jalali || ((task as any)?.actual_start ? formatDate((task as any)?.actual_start) : "شروع نشده")}
</span>
</div>
<div>
<span className="text-slate-400">پایان واقعی: </span>
<span className="font-medium text-slate-700">
{(task as any)?.actual_end_jalali || ((task as any)?.actual_end ? formatDate((task as any)?.actual_end) : "تکمیل نشده")}
</span>
</div>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
مدت زمان برآورد شده (روز)
</label>
<input
type="number"
min={0}
value={durationDays}
onChange={(e) => {
const next = reconcileDates(
{
start: plannedStart,
end: plannedEnd,
days: e.target.value ? Number(e.target.value) : "",
},
"days",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="مثال: ۵"
className={inputCls}
/>
<p className="mt-1 text-[11px] leading-4 text-slate-400">
روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
توضیحات
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="مشخصات و الزامات کار..."
className="w-full rounded-xl border border-slate-300 p-2.5 text-sm text-slate-800 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<ResourceSectionInputs
humanResources={humanResources}
setHumanResources={setHumanResources}
materials={materials}
setMaterials={setMaterials}
/>
{/* بخش روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF) */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<Link2 size={14} className="text-sky-600" />
<span>وابستگی‌ها و روابط پیش‌نیازی (FS, SS, FF, SF)</span>
</span>
{currentTaskRelations.length > 0 && (
<span className="rounded-full bg-sky-100 text-sky-700 px-2 py-0.5 text-[10px] font-bold">
{currentTaskRelations.length} رابطه
</span>
)}
</div>
{/* لیست وابستگی‌های موجود */}
<div className="space-y-1.5">
{currentTaskRelations.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400">
هیچ رابطه وابستگی برای این وظیفه ثبت نشده است.
</p>
) : (
currentTaskRelations.map((r) => {
const isIncoming = r.to_task_id === taskId;
const otherTaskId = isIncoming ? r.from_task_id : r.to_task_id;
const otherTask = allTasks.find(
(t) => String(t.id || t.op_wp_id) === otherTaskId
);
const otherTitle =
(isIncoming ? r.from_task_title : r.to_task_title) ||
otherTask?.title ||
otherTask?.subject ||
"وظیفه مرتبط";
return (
<div
key={r.id}
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-2xs"
>
<div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold text-sky-800 border border-sky-200">
{r.relation_type}
</span>
<span className="text-[11px] text-slate-500">
{isIncoming ? "پیش‌نیاز این وظیفه:" : "پس‌نیاز این وظیفه:"}
</span>
<span className="font-semibold text-slate-800 truncate max-w-[200px]">
{otherTitle}
</span>
{r.lag_days !== 0 && (
<span className="text-[10px] text-slate-400">
(تاخیر: {r.lag_days > 0 ? `+${r.lag_days}` : r.lag_days} روز)
</span>
)}
</div>
<button
type="button"
disabled={deleteRelationMutation.isPending}
onClick={() => deleteRelationMutation.mutate(r.id)}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
title="حذف رابطه"
>
<Trash2 size={14} />
</button>
</div>
);
})
)}
</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">
<div className="sm:col-span-4">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
<select
value={relTargetId}
onChange={(e) => setRelTargetId(e.target.value)}
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"
>
<option value="">انتخاب وظیفه...</option>
{validRelationTargets.map((t) => {
const tKey = String(t.id || t.op_wp_id);
return (
<option key={tKey} value={tKey}>
{t.title || t.subject}
</option>
);
})}
</select>
</div>
<div className="sm:col-span-3">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">نقش رابطه</label>
<select
value={relDirection}
onChange={(e) => setRelDirection(e.target.value as any)}
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"
>
<option value="predecessor">پیش‌نیاز این وظیفه است</option>
<option value="successor">پس‌نیاز این وظیفه است</option>
</select>
</div>
<div className="sm:col-span-3">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">نوع رابطه</label>
<select
value={relType}
onChange={(e) => setRelType(e.target.value as any)}
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"
>
<option value="FS">FS (پایان به شروع)</option>
<option value="SS">SS (شروع به شروع)</option>
<option value="FF">FF (پایان به پایان)</option>
<option value="SF">SF (شروع به پایان)</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">تاخیر (روز)</label>
<input
type="number"
value={relLag}
onChange={(e) => setRelLag(parseInt(e.target.value) || 0)}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
/>
</div>
</div>
<button
type="button"
disabled={addRelationMutation.isPending || !relTargetId}
onClick={() => {
if (!relTargetId) {
toast.error("لطفاً یک وظیفه مرتبط انتخاب کنید");
return;
}
const from_task_id = relDirection === "predecessor" ? relTargetId : taskId;
const to_task_id = relDirection === "predecessor" ? taskId : relTargetId;
addRelationMutation.mutate({
from_task_id,
to_task_id,
relation_type: relType,
lag_days: relLag,
});
}}
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 disabled:opacity-50 transition-colors"
>
<Plus size={13} />
<span>{addRelationMutation.isPending ? "در حال ثبت رابطه..." : "ثبت رابطه جدید"}</span>
</button>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
{!showConfirmDelete && (
<button
type="button"
onClick={() => setShowConfirmDelete(true)}
className="inline-flex h-9 items-center gap-1 rounded-xl text-xs font-medium text-red-600 hover:bg-red-50 px-2 transition-colors"
>
<Trash2 size={14} />
<span>حذف وظیفه</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className={btnPrimary}
>
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
</button>
</div>
</div>
</form>
</Modal>
);
}
+935
View File
@@ -0,0 +1,935 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
Camera,
Check,
CheckCircle2,
ClipboardCheck,
FileDown,
Minus,
Plus,
ShieldAlert,
ShieldCheck,
Trash2,
X,
} from "lucide-react";
import {
addTaskQcQuestion,
createDefect,
createTaskQcDefinition,
deleteTaskQcQuestion,
getQcTemplates,
getTaskQc,
putTaskQcResult,
resolveDefect,
uploadFile,
} from "../lib/api";
import type { AnswerValue, Defect, QcPhase, TaskDetail, TaskQcQuestion } from "../lib/api";
import { Chip, ProgressBar, Modal, btnPrimary, btnSecondary, inputCls, Combobox } from "./ui";
import { defectStatusMeta, relativeTime, severityMeta, severityOptions } from "../lib/format";
interface TaskQcSectionProps {
task: TaskDetail;
canExecuteQc: boolean;
canDefineQc: boolean;
}
export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSectionProps) {
const taskId = String(task.id || task.op_wp_id || "");
const queryClient = useQueryClient();
const [activePhase, setActivePhase] = useState<QcPhase>("pre");
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [showAddQuestionModal, setShowAddQuestionModal] = useState(false);
const [defectTargetQuestion, setDefectTargetQuestion] = useState<TaskQcQuestion | null>(null);
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
const [resolutionNotes, setResolutionNotes] = useState("");
// Load Task QC Data
const qcQuery = useQuery({
queryKey: ["task-qc", taskId],
queryFn: () => getTaskQc(taskId),
});
const templatesQuery = useQuery({
queryKey: ["qc-templates"],
queryFn: getQcTemplates,
enabled: showTemplateModal,
});
const qcState = qcQuery.data;
const readiness = qcState?.readiness;
const definitions = qcState?.definitions ?? [];
const questions = definitions.flatMap((d) => d.questions);
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
// Mutations
const invalidateQc = () => {
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
};
const answerMutation = useMutation({
mutationFn: ({ questionId, answer, photoId }: { questionId: string; answer: AnswerValue | null; photoId?: string | null }) =>
putTaskQcResult(questionId, { answer, photo_id: photoId }),
onSuccess: (_, variables) => {
invalidateQc();
if (variables.answer === null) {
toast.success("انتخاب بازرسی لغو شد");
} else {
toast.success("نتیجه بازرسی ثبت شد");
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ثبت نتیجه بازرسی");
},
});
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 });
}
};
const applyTemplateMutation = useMutation({
mutationFn: (templateId: string) =>
createTaskQcDefinition(taskId, { template_id: templateId }),
onSuccess: () => {
invalidateQc();
setShowTemplateModal(false);
setSelectedTemplateId("");
toast.success("چک‌لیست از قالب بارگذاری شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در بارگذاری قالب");
},
});
const addQuestionMutation = useMutation({
mutationFn: async ({ text, phase, required }: { text: string; phase: QcPhase; required: boolean }) => {
let definitionId = definitions[0]?.id;
if (!definitionId) {
const newDef = await createTaskQcDefinition(taskId, { name: "چک‌لیست کنترل کیفیت" });
definitionId = newDef.id;
}
return addTaskQcQuestion(definitionId, { text, phase, required });
},
onSuccess: () => {
invalidateQc();
setShowAddQuestionModal(false);
toast.success("سؤال جدید به چک‌لیست اضافه شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در افزودن سؤال");
},
});
const deleteQuestionMutation = useMutation({
mutationFn: (questionId: string) => deleteTaskQcQuestion(questionId),
onSuccess: () => {
invalidateQc();
toast.success("سؤال با موفقیت حذف شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در حذف سؤال");
},
});
const resolveDefectMutation = useMutation({
mutationFn: ({ defectId, notes }: { defectId: string; notes: string }) =>
resolveDefect(defectId, notes),
onSuccess: () => {
invalidateQc();
setDefectToResolve(null);
setResolutionNotes("");
toast.success("عیب به عنوان رفع‌شده ثبت گردید");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در رفع عیب");
},
});
// 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. بنر وضعیت آمادگی شروع و اتمام کار */}
{readiness && (
<div>
{!readiness.can_start ? (
<div className="rounded-xl border border-amber-300 bg-amber-50/90 p-3 text-amber-900 shadow-sm">
<div className="flex items-center gap-2 font-bold text-xs">
<AlertTriangle size={16} className="text-amber-600 shrink-0" />
<span>شروع اجرای وظیفه نیازمند بازرسی پیش از اجرا است</span>
</div>
{readiness.block_reasons.length > 0 && (
<ul className="mt-1.5 list-inside list-disc text-[11px] leading-5 text-amber-800 ps-1">
{readiness.block_reasons.map((reason, idx) => (
<li key={idx}>{reason}</li>
))}
</ul>
)}
</div>
) : readiness.all.total > 0 && readiness.all.pending === 0 && readiness.all.failed === 0 ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50/80 p-3 text-emerald-900 flex items-center gap-2 text-xs font-bold">
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<span>تمام بازرسی‌های کیفی با موفقیت تأیید شدند — پیشرفت ۱۰۰٪ مجاز است</span>
</div>
) : (
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-3 text-sky-900 flex items-center justify-between text-xs font-semibold">
<div className="flex items-center gap-2">
<ShieldCheck size={16} className="text-sky-600 shrink-0" />
<span>آمادگی شروع تأیید شده است</span>
</div>
<span className="text-[11px] text-sky-700 font-bold tabular-nums">
{answeredCount} از {totalQuestions} بازرسی انجام شد
</span>
</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>
</div>
<ProgressBar value={qcPercent} />
</div>
)}
</div>
)}
{/* 2. هدر ابزارهای چک‌لیست و تب‌های ۳ مرحله */}
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap border-b border-slate-200 pb-2.5">
<div className="flex items-center gap-1.5">
<ClipboardCheck size={16} className="text-sky-600" />
<span className="text-xs font-bold text-slate-800">چک‌لیست بازرسی کیفی</span>
</div>
<div className="flex items-center gap-1.5">
{canDefineQc && (
<>
<button
type="button"
onClick={() => setShowTemplateModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-white border border-slate-200 px-2.5 text-[11px] font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 transition-colors shadow-sm"
>
<FileDown size={13} className="text-sky-600" />
<span>بارگذاری از قالب</span>
</button>
<button
type="button"
onClick={() => setShowAddQuestionModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-600 px-2.5 text-[11px] font-semibold text-white hover:bg-sky-700 transition-colors shadow-sm"
>
<Plus size={13} />
<span>افزودن سؤال</span>
</button>
</>
)}
</div>
</div>
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */}
<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={`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>۱. پیش از اجرا</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={`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>۲. حین اجرا</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={`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>۳. تحویل و پس از اجرا</span>
</button>
</div>
{/* لیست سؤالات فاز انتخاب‌شده */}
{currentPhaseQuestions.length === 0 ? (
<div className="py-6 text-center text-xs text-slate-400">
<p>سؤالی در این مرحله تعریف نشده است.</p>
{canDefineQc && (
<button
type="button"
onClick={() => setShowAddQuestionModal(true)}
className="mt-2 text-sky-600 hover:underline text-xs inline-flex items-center gap-1 font-semibold"
>
<Plus size={13} />
افزودن اولین سؤال
</button>
)}
</div>
) : (
<div className="space-y-2">
{currentPhaseQuestions.map((q, idx) => {
const latestAns = q.result?.answer;
const hasFailed = latestAns === "fail";
const isSatisfied = latestAns === "pass" || latestAns === "na";
return (
<div
key={q.id}
className={`rounded-xl border bg-white p-3 shadow-xs transition-all ${
hasFailed
? "border-red-200 bg-red-50/20"
: isSatisfied
? "border-emerald-100"
: "border-slate-200"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-slate-100 text-[10px] font-bold text-slate-600">
{idx + 1}
</span>
<p className="text-xs font-bold text-slate-800 leading-5">{q.text}</p>
</div>
{q.result?.evaluated_by && (
<p className="mt-1 text-[10px] text-slate-400 ms-6">
بررسی توسط {q.result.evaluated_by} — {relativeTime(q.result.evaluated_at)}
</p>
)}
</div>
{canDefineQc && (
<button
type="button"
onClick={() => deleteQuestionMutation.mutate(q.id)}
disabled={deleteQuestionMutation.isPending}
title="حذف سؤال"
className="text-slate-300 hover:text-red-500 p-1 rounded transition-colors"
>
<Trash2 size={13} />
</button>
)}
</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}
onClick={() => handleToggleAnswer(q.id, latestAns, "pass")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "pass"
? "bg-emerald-600 text-white shadow-sm ring-2 ring-emerald-200"
: "bg-slate-100 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
}`}
>
<Check size={13} strokeWidth={2.2} />
<span>قبول</span>
</button>
{/* دکمه رد / Fail */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "fail")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "fail"
? "bg-red-600 text-white shadow-sm ring-2 ring-red-200"
: "bg-slate-100 text-slate-600 hover:bg-red-50 hover:text-red-700"
}`}
>
<X size={13} strokeWidth={2.2} />
<span>مردود</span>
</button>
{/* دکمه مطرح نیست */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "na")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "na"
? "bg-slate-700 text-white shadow-sm ring-2 ring-slate-300"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<Minus size={15} strokeWidth={3} />
<span>مطرح نیست</span>
</button>
</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">
<span className="text-[11px] font-medium text-emerald-800 flex items-center gap-1">
<Check size={13} className="text-emerald-600" />
<span>این مورد تأیید شده است</span>
</span>
<label className="inline-flex items-center gap-1 rounded-md bg-emerald-600 px-2 py-1 text-[11px] font-bold text-white hover:bg-emerald-700 transition-colors shadow-xs cursor-pointer">
<Camera size={12} />
<span>{q.result?.photo_url ? "تغییر تصویر مستند" : "آپلود تصویر مستند (قبول)"}</span>
<input
type="file"
accept="image/*"
className="hidden"
disabled={!canExecuteQc || answerMutation.isPending}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await uploadFile(file);
answerMutation.mutate({ questionId: q.id, answer: "pass", photoId: res.id });
toast.success("تصویر مستند تأییدیه ذخیره شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
}
}}
/>
</label>
</div>
{q.result?.photo_url && (
<div className="flex items-center justify-between gap-2 pt-1 border-t border-emerald-200/60">
<div className="flex items-center gap-2">
<a href={q.result.photo_url} target="_blank" rel="noreferrer" className="block shrink-0">
<img src={q.result.photo_url} alt="مستند قبولی" className="h-10 w-10 rounded object-cover border border-emerald-200" />
</a>
<a href={q.result.photo_url} target="_blank" rel="noreferrer" className="text-[10px] text-emerald-700 hover:underline">
مشاهده تصویر پیوست
</a>
</div>
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => answerMutation.mutate({ questionId: q.id, answer: "pass", photoId: "" })}
className="text-[10px] text-red-500 hover:underline"
>
حذف تصویر
</button>
</div>
)}
</div>
)}
{/* در صورت مردود بودن: اکشن ثبت عیب */}
{hasFailed && (
<div className="mt-2 rounded-lg bg-red-50 p-2 flex items-center justify-between text-xs border border-red-100">
<span className="text-[11px] font-medium text-red-700">این مورد مردود شده است</span>
<button
type="button"
onClick={() => setDefectTargetQuestion(q)}
className="inline-flex items-center gap-1 rounded-md bg-red-600 px-2 py-0.5 text-[11px] font-bold text-white hover:bg-red-700 transition-colors shadow-xs"
>
<ShieldAlert size={12} />
<span>ثبت عدم انطباق (عیب)</span>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 3. لیست عیوب و عدم انطباق‌های وظیفه */}
{task.defects && task.defects.length > 0 && (
<div className="rounded-xl border border-slate-200 bg-white p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
<ShieldAlert size={14} className="text-red-600" />
<span>عدم انطباق‌ها و عیوب ثبت‌شده ({task.defects.length})</span>
</span>
{task.open_defects != null && task.open_defects > 0 && (
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-[10px] font-bold">
{task.open_defects} مورد باز
</span>
)}
</div>
<div className="space-y-2">
{task.defects.map((defect) => {
const isResolved = defect.status === "resolved";
const sev = severityMeta(defect.severity);
const dst = defectStatusMeta(defect.status);
return (
<div
key={defect.id}
className={`rounded-lg border p-2.5 text-xs transition-colors ${
isResolved ? "border-slate-100 bg-slate-50/60" : "border-red-200 bg-red-50/30"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-bold text-slate-800 text-xs block">{defect.title}</span>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Chip tone={dst.tone}>{dst.label}</Chip>
<Chip tone={sev.tone}>{sev.label}</Chip>
<span className="text-[10px] text-slate-400">{relativeTime(defect.created_at)}</span>
</div>
</div>
{!isResolved && canExecuteQc && (
<button
type="button"
onClick={() => setDefectToResolve(defect)}
className="rounded-lg bg-emerald-600 text-white px-2.5 py-1 text-[11px] font-bold hover:bg-emerald-700 transition-colors shadow-xs shrink-0"
>
رفع شد
</button>
)}
</div>
{defect.corrective_action && (
<p className="mt-1.5 text-[11px] leading-4 text-slate-600 bg-white/80 p-1.5 rounded border border-slate-100">
<span className="font-semibold text-slate-700">اقدام اصلاحی: </span>
{defect.corrective_action}
</p>
)}
{defect.resolution_notes && (
<p className="mt-1 text-[11px] text-emerald-700 font-medium">
توضیح رفع: {defect.resolution_notes}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* --- Modal: بارگذاری از قالب سراسری --- */}
{showTemplateModal && (
<Modal
open
title="بارگذاری چک‌لیست از قالب‌های سراسری"
onClose={() => {
setShowTemplateModal(false);
setSelectedTemplateId("");
}}
>
<div className="space-y-3.5">
<p className="text-xs text-slate-500 leading-5">
با انتخاب قالب، تمام سؤالات آن در این وظیفه کپی می‌شوند و می‌توانید آزادانه آن‌ها را تغییر دهید.
</p>
<div>
<label className="mb-1.5 block text-xs font-semibold text-slate-700">
انتخاب قالب چک‌لیست
</label>
{templatesQuery.isPending ? (
<p className="py-3 text-center text-xs text-slate-400">در حال دریافت قالب‌ها...</p>
) : (templatesQuery.data ?? []).length === 0 ? (
<p className="py-3 text-center text-xs text-slate-400">هنوز هیچ قالبی در سیستم تعریف نشده است.</p>
) : (
<Combobox
value={selectedTemplateId}
onChange={setSelectedTemplateId}
placeholder="یک قالب را انتخاب کنید..."
searchPlaceholder="جستجوی نام قالب یا نوع فعالیت..."
emptyMessage="قالبی با این عنوان پیدا نشد"
options={(templatesQuery.data ?? []).map((tpl) => ({
value: tpl.id,
label: tpl.name,
hint: tpl.activity_type ? `${tpl.activity_type} (${tpl.questions.length} سؤال)` : `${tpl.questions.length} سؤال`,
}))}
/>
)}
</div>
{selectedTemplate && (
<div className="rounded-xl border border-sky-200/80 bg-sky-50/60 p-3 text-xs space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-sky-950">{selectedTemplate.name}</span>
<span className="rounded bg-sky-100 px-2 py-0.5 font-semibold text-sky-800 text-[11px]">
{selectedTemplate.questions.length} سؤال
</span>
</div>
{selectedTemplate.activity_type && (
<p className="text-[11px] text-slate-600">
نوع فعالیت: <span className="font-semibold text-slate-800">{selectedTemplate.activity_type}</span>
</p>
)}
{selectedTemplate.description && (
<p className="text-[11px] text-slate-500 leading-4">{selectedTemplate.description}</p>
)}
<div className="flex flex-wrap gap-1.5 pt-1">
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
پیش از اجرا: {selectedTemplate.questions.filter((q) => q.phase === "pre").length}
</span>
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
حین اجرا: {selectedTemplate.questions.filter((q) => q.phase === "during").length}
</span>
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
پس از اجرا: {selectedTemplate.questions.filter((q) => q.phase === "after").length}
</span>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => {
setShowTemplateModal(false);
setSelectedTemplateId("");
}}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={!selectedTemplateId || applyTemplateMutation.isPending}
onClick={() => {
if (selectedTemplateId) {
applyTemplateMutation.mutate(selectedTemplateId);
}
}}
className={btnPrimary}
>
{applyTemplateMutation.isPending ? "در حال بارگذاری..." : "بارگذاری قالب"}
</button>
</div>
</div>
</Modal>
)}
{/* --- Modal: افزودن سؤال جدید --- */}
{showAddQuestionModal && (
<AddQuestionModal
open={showAddQuestionModal}
defaultPhase={activePhase}
pending={addQuestionMutation.isPending}
onClose={() => setShowAddQuestionModal(false)}
onSubmit={(data) => addQuestionMutation.mutate(data)}
/>
)}
{/* --- Modal: ثبت عدم انطباق (عیب) --- */}
{defectTargetQuestion && (
<CreateDefectModal
open={Boolean(defectTargetQuestion)}
taskId={taskId}
question={defectTargetQuestion}
onClose={() => setDefectTargetQuestion(null)}
onCreated={() => {
setDefectTargetQuestion(null);
invalidateQc();
}}
/>
)}
{/* --- Modal: رفع عیب --- */}
{defectToResolve && (
<Modal open title="ثبت رفع عدم انطباق" onClose={() => setDefectToResolve(null)}>
<div className="space-y-3">
<p className="text-xs text-slate-600">
آیا عیب «<span className="font-bold">{defectToResolve.title}</span>» برطرف شده است؟
</p>
<div>
<label className="block text-[11px] font-medium text-slate-600 mb-1">
توضیحات اقدام اصلاحی انجام‌شده (اختیاری)
</label>
<textarea
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
placeholder="توضیح دهید چه اقدامی برای رفع این عیب انجام شد..."
className={`${inputCls} h-20 py-2`}
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button
type="button"
onClick={() => setDefectToResolve(null)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={resolveDefectMutation.isPending}
onClick={() =>
resolveDefectMutation.mutate({
defectId: defectToResolve.id,
notes: resolutionNotes,
})
}
className={btnPrimary}
>
{resolveDefectMutation.isPending ? "در حال ثبت..." : "تأیید رفع عیب"}
</button>
</div>
</div>
</Modal>
)}
</div>
);
}
function AddQuestionModal({
open,
defaultPhase,
pending,
onClose,
onSubmit,
}: {
open: boolean;
defaultPhase: QcPhase;
pending: boolean;
onClose: () => void;
onSubmit: (data: { text: string; phase: QcPhase; required: boolean }) => void;
}) {
const [text, setText] = useState("");
const [phase, setPhase] = useState<QcPhase>(defaultPhase);
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) {
setError("متن سؤال الزامی است");
return;
}
onSubmit({ text: text.trim(), phase, required: true });
};
return (
<Modal open={open} title="افزودن سؤال بازرسی کیفی" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{error && <p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">{error}</p>}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">مرحله بازرسی</label>
<select
value={phase}
onChange={(e) => setPhase(e.target.value as QcPhase)}
className={inputCls}
>
<option value="pre">۱. پیش از اجرا (Pre-Execution)</option>
<option value="during">۲. حین اجرا (During-Execution)</option>
<option value="after">۳. تحویل و پس از اجرا (Post-Execution)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">متن سؤال / معیار بازرسی *</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="مثال: آیا ابعاد و فاصله میلگردها طبق نقشه اجرایی رعایت شده است؟"
className={`${inputCls} h-20 py-2`}
required
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={pending} className={btnPrimary}>
{pending ? "در حال ثبت..." : "افزودن سؤال"}
</button>
</div>
</form>
</Modal>
);
}
function CreateDefectModal({
open,
taskId,
question,
onClose,
onCreated,
}: {
open: boolean;
taskId: string;
question: TaskQcQuestion;
onClose: () => void;
onCreated: () => void;
}) {
const [title, setTitle] = useState(`عدم انطباق: ${question.text}`);
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState("medium");
const [correctiveAction, setCorrectiveAction] = useState("");
const [photoId, setPhotoId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const defectMutation = useMutation({
mutationFn: (payload: any) => createDefect(payload),
onSuccess: () => {
toast.success("عدم انطباق با موفقیت ثبت شد");
onCreated();
},
onError: (err: any) => {
setError(err?.message || "خطا در ثبت عدم انطباق");
},
});
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const res = await uploadFile(file);
setPhotoId(res.id);
toast.success("تصویر ضمیمه شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
} finally {
setUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError("عنوان عدم انطباق الزامی است");
return;
}
defectMutation.mutate({
task_ref: taskId,
question_id: question.id,
title: title.trim(),
description: description.trim(),
severity,
corrective_action: correctiveAction.trim(),
photo_id: photoId,
});
};
return (
<Modal open={open} title="ثبت عدم انطباق کیفی (Defect)" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{error && <p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">{error}</p>}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">عنوان عدم انطباق *</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputCls}
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">شرح و جزئیات مشکل</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="توضیحات بیشتر درباره مورد مشاهده‌شده..."
className={`${inputCls} h-14 py-2`}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">شدت عدم انطباق</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className={inputCls}
>
{severityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">اقدام اصلاحی لازم</label>
<textarea
value={correctiveAction}
onChange={(e) => setCorrectiveAction(e.target.value)}
placeholder="چه اصلاحی باید انجام شود تا تأیید مجدد صادر شود..."
className={`${inputCls} h-16 py-2`}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">تصویر مستند</label>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
disabled={uploading}
className="text-xs text-slate-600 file:mr-2 file:py-1 file:px-2.5 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-sky-50 file:text-sky-700 hover:file:bg-sky-100"
/>
{uploading && <span className="text-[11px] text-sky-600 block mt-1">در حال آپلود...</span>}
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={defectMutation.isPending || uploading} className={btnPrimary}>
{defectMutation.isPending ? "در حال ثبت..." : "ثبت عدم انطباق"}
</button>
</div>
</form>
</Modal>
);
}
+630
View File
@@ -0,0 +1,630 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
Menu,
Search,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { toast } from "sonner";
import { ApiError } from "../lib/api";
import { useAuthStore } from "../store/auth";
import { visibleTabs } from "../lib/roles";
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
export function apiErrorToast(err: unknown, fallback: string): void {
toast.error(err instanceof ApiError ? err.message : fallback);
}
// ----------------------------------------
// کلاس‌های مشترک
// ----------------------------------------
export const btnPrimary =
"inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-sky-600 px-4 text-sm font-semibold text-white transition-colors active:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50";
export const btnSecondary =
"inline-flex h-11 items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 text-sm font-semibold text-slate-700 transition-colors active:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50";
export const btnDanger =
"inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-red-600 px-4 text-sm font-semibold text-white transition-colors active:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50";
export const inputCls =
"h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm text-slate-800 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:opacity-60";
// ----------------------------------------
// پوسته ستون موبایلی
// ----------------------------------------
export function PhoneShell({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-dvh justify-center bg-slate-200">
<div className="relative w-full min-h-dvh max-w-[420px] bg-slate-50 shadow-xl">{children}</div>
</div>
);
}
// ----------------------------------------
// هدر چسبان
// ----------------------------------------
export function PageHeader({
title,
sub,
onBack,
right,
menu,
}: {
title: string;
sub?: ReactNode;
onBack?: () => void;
right?: ReactNode;
/** منوی همبرگری پنل‌ها (PanelsMenu) — مثل ERP در گوشه بالا-راست هدر */
menu?: ReactNode;
}) {
const user = useAuthStore((s) => s.user);
const fullName = user?.display_name || user?.username || "کاربر";
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 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-10 w-10 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
>
<ChevronRight size={22} />
</button>
)}
<div className="min-w-0 flex-1 px-1">
<h1 className="truncate text-base font-bold text-slate-800 leading-tight">{title}</h1>
{displaySub && <div className="truncate text-[11px] text-slate-500 font-normal mt-0.5">{displaySub}</div>}
</div>
{right}
</header>
);
}
// ----------------------------------------
// منوی پنل‌ها — ناوبری بین سامانه‌های مجموعه
// (ERP: انبار/تدارکات — PM: همین اپ) مثل منوی پنل‌های ERP
// ----------------------------------------
/** پیشوند سامانه ERP — پیش‌فرض همان دامنه (PM زیر /pm روی دامنه ERP سرو می‌شود) */
const ERP_URL =
(import.meta.env?.VITE_ERP_URL as string | undefined)?.trim() || "";
type PanelMenuItem =
| { kind: "divider" }
| { kind: "link"; label: string; href: string }
| { kind: "current"; label: string }
| { kind: "button"; label: string; onClick: () => void };
export function PanelsMenu() {
const navigate = useNavigate();
const clear = useAuthStore((s) => s.clear);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const logout = () => {
clear();
navigate("/login", { replace: true });
};
const user = useAuthStore((s) => s.user);
let role = (user?.role as string) || "";
let erpRole = "";
let erpPanels: Record<string, string> = {};
try {
const raw = localStorage.getItem("procurement-auth-v1");
if (raw) {
const parsed = JSON.parse(raw);
erpRole = (parsed?.state?.user?.role as string) || "";
erpPanels = (parsed?.state?.user?.panels as Record<string, string>) || {};
if (!role) role = erpRole;
}
} catch {}
const isAdmin = erpRole === "admin" || role === "admin";
const hasPanelsConfig = Boolean(erpPanels && Object.keys(erpPanels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(erpPanels.procurement)
: erpRole === "procurement_manager" || role === "project_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(erpPanels.inventory)
: erpRole === "pm" || erpRole === "supervisor" || role === "project_supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(erpPanels.pm)
: erpRole === "pm" || erpRole === "supervisor" || role === "project_manager" || role === "project_supervisor");
const canManageOrg =
isAdmin ||
erpRole === "procurement_manager" ||
erpRole === "pm" ||
(role === "project_manager" && erpRole !== "supervisor");
const items: PanelMenuItem[] = [];
if (hasInventory) {
items.push({ kind: "link", label: "انبار", href: `${ERP_URL}/inventory` });
}
if (hasProcurement) {
const procHref =
erpPanels.procurement === "limited" && !isAdmin
? `${ERP_URL}/wizard`
: `${ERP_URL}/`;
items.push({ kind: "link", label: "تدارکات", href: procHref });
}
if (hasPm) {
items.push({ kind: "current", label: "مدیریت پروژه" });
}
if (canManageOrg) {
items.push({ kind: "link", label: "سازمان‌دهی", href: `${ERP_URL}/organization` });
}
items.push({ kind: "divider" });
items.push({ kind: "button", label: "خروج از حساب", onClick: logout });
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label="منو"
title="منو"
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>
{open && (
<div className="absolute top-12 start-0 z-50 min-w-56 rounded-xl border border-slate-200 bg-white p-1.5 shadow-lg">
<div className="px-3 py-1.5 text-[11px] font-bold text-slate-500">پنل‌ها</div>
{items.map((it, i) =>
it.kind === "divider" ? (
<div key={i} className="my-1 border-t border-slate-100" />
) : it.kind === "link" ? (
<a
key={i}
href={it.href}
/* همان‌تب و در سطح کل صفحه — PM ممکن است داخل iframe پنل ERP باشد */
target="_top"
onClick={(e) => {
setOpen(false);
if (window.top && window.top !== window) {
e.preventDefault();
window.top.location.href = it.href;
}
}}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline text-slate-700 hover:bg-slate-50 active:bg-slate-100"
>
{it.label}
</a>
) : it.kind === "current" ? (
<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>
) : (
<button
key={i}
type="button"
onClick={() => {
setOpen(false);
it.onClick();
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-slate-700 hover:bg-slate-50 active:bg-slate-100"
>
{it.label}
</button>
),
)}
</div>
)}
</div>
);
}
// ----------------------------------------
// نوار تب پایین — تب‌ها از مجوزهای کاربر ساخته می‌شوند
// ----------------------------------------
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 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 }) =>
`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 }) => (
<>
<tab.icon size={22} strokeWidth={isActive ? 2.3 : 1.8} />
<span>{tab.label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
);
}
// ----------------------------------------
// کارت و چیپ
// ----------------------------------------
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
return <div className={`rounded-2xl border border-slate-200/70 bg-white shadow-sm ${className}`}>{children}</div>;
}
const toneClasses: Record<Tone, string> = {
red: "bg-red-100 text-red-700",
amber: "bg-amber-100 text-amber-700",
green: "bg-emerald-100 text-emerald-700",
sky: "bg-sky-100 text-sky-700",
slate: "bg-slate-200 text-slate-600",
};
export function Chip({
tone = "slate",
children,
className = "",
title,
}: {
tone?: Tone;
children: ReactNode;
className?: string;
title?: string;
}) {
return (
<span
title={title}
className={`inline-flex max-w-full items-center gap-1 overflow-hidden rounded-full px-2 py-0.5 text-[11px] font-medium ${toneClasses[tone]} ${className}`}
>
<span className="truncate">{children}</span>
</span>
);
}
// ----------------------------------------
// نوار پیشرفت
// ----------------------------------------
export function ProgressBar({ value, className = "" }: { value: number; className?: string }) {
const v = Math.max(0, Math.min(100, Math.round(value)));
return (
<div className={`h-1.5 w-full overflow-hidden rounded-full bg-slate-200 ${className}`}>
<div
className={`h-full rounded-full ${v >= 100 ? "bg-emerald-500" : "bg-sky-500"}`}
style={{ width: `${v}%` }}
/>
</div>
);
}
// ----------------------------------------
// حالت خالی و خطا
// ----------------------------------------
export function EmptyState({
icon: Icon,
title,
description,
}: {
icon: LucideIcon;
title: string;
description?: string;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<Icon size={22} />
</div>
<p className="text-sm font-medium text-slate-500">{title}</p>
{description && <p className="px-6 text-xs leading-5 text-slate-400">{description}</p>}
</div>
);
}
export function ErrorState({ message, onRetry }: { message?: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500">
<AlertTriangle size={22} />
</div>
<p className="px-6 text-sm font-medium text-slate-600">{message ?? "خطا در دریافت اطلاعات"}</p>
<button type="button" onClick={onRetry} className={`${btnSecondary} h-10 px-5 text-xs`}>
تلاش مجدد
</button>
</div>
);
}
// ----------------------------------------
// اسکلتون بارگذاری
// ----------------------------------------
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-slate-200/80 ${className}`} />;
}
export function CardSkeleton() {
return (
<div className="rounded-2xl border border-slate-200/70 bg-white p-4 shadow-sm">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="mt-2 h-3 w-1/3" />
<Skeleton className="mt-3 h-1.5 w-full" />
</div>
);
}
// ----------------------------------------
// کومبوباکس (سلکت با قابلیت جستجو)
// ----------------------------------------
export interface ComboboxOption {
value: string;
label: string;
hint?: string;
}
export function Combobox({
value,
options,
onChange,
placeholder = "انتخاب کنید...",
searchPlaceholder = "جستجو...",
emptyMessage = "موردی یافت نشد",
disabled = false,
}: {
value: string;
options: ComboboxOption[];
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const selected = options.find((o) => o.value === value) ?? null;
const normalized = query.trim().toLowerCase();
const filtered = normalized
? options.filter(
(o) =>
o.label.toLowerCase().includes(normalized) ||
(o.hint ?? "").toLowerCase().includes(normalized),
)
: options;
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => {
setOpen((v) => !v);
setQuery("");
}}
className={`${inputCls} flex items-center justify-between gap-2 text-right`}
>
<span className={`min-w-0 flex-1 truncate ${selected ? "" : "text-slate-400"}`}>
{selected?.label ?? placeholder}
</span>
<ChevronDown size={16} className={`shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute inset-x-0 top-full z-40 mt-1.5 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
<div className="border-b border-slate-100 p-2">
<div className="relative">
<Search size={14} className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
e.preventDefault();
pick(filtered[0].value);
}
}}
placeholder={searchPlaceholder}
className={`${inputCls} h-9 ps-8 text-xs`}
/>
</div>
</div>
<div className="max-h-56 overflow-y-auto p-1">
{filtered.length === 0 ? (
<p className="py-4 text-center text-xs text-slate-400">{emptyMessage}</p>
) : (
filtered.map((o) => (
<button
key={o.value}
type="button"
onClick={() => pick(o.value)}
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-right text-xs transition-colors ${
o.value === value
? "bg-sky-50 font-bold text-sky-700"
: "text-slate-700 hover:bg-slate-50 active:bg-slate-100"
}`}
>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.hint && <span className="shrink-0 text-[10px] text-slate-400">{o.hint}</span>}
{o.value === value && <Check size={14} className="shrink-0 text-sky-600" />}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
// ----------------------------------------
// مودال (بات‌شیت موبایلی)
// ----------------------------------------
export function Modal({
open,
title,
onClose,
children,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-900/50 sm:items-center sm:p-4"
onClick={onClose}
>
<div
className="max-h-[85dvh] w-full max-w-[420px] overflow-y-auto rounded-t-2xl bg-white p-4 pb-8 shadow-xl sm:rounded-2xl sm:pb-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-800">{title}</h3>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-400 active:bg-slate-100"
>
<X size={18} />
</button>
</div>
{children}
</div>
</div>
);
}
// ----------------------------------------
// آواتار حرف اول
// ----------------------------------------
export function InitialAvatar({ name, className = "" }: { name: string | null | undefined; className?: string }) {
const initial = (name ?? "").trim().charAt(0).toUpperCase();
return (
<span
className={`flex shrink-0 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-sky-700 ${className || "h-6 w-6"}`}
>
{initial || "؟"}
</span>
);
}
export function ConfirmSheet({
open,
title,
message,
confirmLabel = "حذف",
pending,
onClose,
onConfirm,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
pending: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Modal open={open} title={title} onClose={onClose}>
<p className="text-xs leading-6 text-slate-600">{message}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending} onClick={onConfirm} className={btnDanger}>
{pending ? "در حال حذف..." : confirmLabel}
</button>
</div>
</Modal>
);
}
export { ChevronDown };
@@ -0,0 +1,14 @@
import { useEffect, useRef } from "react";
/** صدا زدن callback هنگام کلیک بیرون از عنصر */
export function useClickOutside<T extends HTMLElement>(onOutside: () => void): React.RefObject<T | null> {
const ref = useRef<T | null>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onOutside]);
return ref;
}
+352
View File
@@ -0,0 +1,352 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Folder, Plus, ShieldCheck, UserPlus } from "lucide-react";
import {
createUser,
getProjects,
getUsers,
setUserProjects,
updateUser,
type ManagedUser,
type Role,
} from "../lib/api";
import {
Card,
Chip,
ErrorState,
Modal,
Skeleton,
btnPrimary,
btnSecondary,
inputCls,
apiErrorToast,
} from "./ui";
import { useAuthStore } from "../store/auth";
import { roleLabels } from "../lib/roles";
/**
* مدیریت کاربران و محدوده دسترسی آن‌ها (فقط مدیر پروژه).
*
* «پروژه‌های اختصاص‌یافته» همان چیزی است که سرپرست پروژه اجازه دیدن و ثبت
* اجرا روی آن‌ها را دارد؛ بدون انتخاب پروژه، سرپرست هیچ داده‌ای نمی‌بیند.
*/
export function UsersAccessPanel() {
const queryClient = useQueryClient();
const me = useAuthStore((s) => s.user);
const usersQuery = useQuery({ queryKey: ["users"], queryFn: getUsers });
const [projectsTarget, setProjectsTarget] = useState<ManagedUser | null>(null);
const [showCreate, setShowCreate] = useState(false);
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["users"] });
const roleMutation = useMutation({
mutationFn: (args: { userId: string; role: Role }) => updateUser(args.userId, { role: args.role }),
onSuccess: (user) => {
toast.success(`نقش ${user.display_name || user.username} به «${user.role_label}» تغییر کرد`);
invalidate();
},
onError: (err) => apiErrorToast(err, "تغییر نقش ناموفق بود"),
});
const users = usersQuery.data ?? [];
return (
<>
<Card className="p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="flex items-center gap-1.5 text-sm font-bold text-slate-700">
<ShieldCheck size={15} className="text-sky-500" />
کاربران و دسترسی‌ها
</h2>
<button
type="button"
onClick={() => setShowCreate(true)}
className="inline-flex h-8 items-center gap-1 rounded-full bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 active:bg-sky-100"
>
<UserPlus size={13} />
کاربر جدید
</button>
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-400">
«مدیر پروژه» به همه پروژه‌ها دسترسی دارد؛ «سرپرست پروژه» فقط پروژه‌های اختصاص‌یافته را می‌بیند و می‌تواند پیشرفت و
نتیجه بازرسی‌ها را ثبت کند.
</p>
{usersQuery.isPending ? (
<div className="mt-3 space-y-2">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : usersQuery.isError ? (
<div className="mt-3">
<ErrorState message="خطا در دریافت کاربران" onRetry={() => void usersQuery.refetch()} />
</div>
) : (
<div className="mt-3 divide-y divide-slate-100">
{users.map((user) => (
<div key={user.id} className="py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-bold text-slate-800">
{user.display_name || user.username}
</p>
<p dir="ltr" className="mt-0.5 text-right text-[10px] text-slate-400">
{user.username}
</p>
</div>
<Chip tone={user.scoped ? "green" : "sky"}>{user.role_label}</Chip>
</div>
<div className="mt-2 flex items-center gap-2">
<select
aria-label={`نقش ${user.username}`}
value={user.role}
disabled={roleMutation.isPending || user.id === me?.id}
onChange={(e) =>
roleMutation.mutate({ userId: user.id, role: e.target.value as Role })
}
className={`${inputCls} h-9 min-w-0 flex-1 px-2 text-[11px]`}
>
{(Object.keys(roleLabels) as Role[]).map((role) => (
<option key={role} value={role}>
{roleLabels[role]}
</option>
))}
</select>
{user.scoped && (
<button
type="button"
onClick={() => setProjectsTarget(user)}
className={`${btnSecondary} h-9 shrink-0 px-2.5 text-[11px]`}
>
<Folder size={13} />
پروژه‌ها ({user.project_ids.length})
</button>
)}
</div>
{user.id === me?.id && (
<p className="mt-1 text-[10px] text-slate-400">نقش حساب فعلی خود را نمی‌توانید تغییر دهید.</p>
)}
</div>
))}
</div>
)}
</Card>
{projectsTarget && (
<ProjectsSheet
user={projectsTarget}
onClose={() => setProjectsTarget(null)}
onDone={() => {
setProjectsTarget(null);
invalidate();
}}
/>
)}
{showCreate && (
<CreateUserSheet
onClose={() => setShowCreate(false)}
onDone={() => {
setShowCreate(false);
invalidate();
}}
/>
)}
</>
);
}
// ----------------------------------------
// تخصیص پروژه به یک کاربر
// ----------------------------------------
function ProjectsSheet({
user,
onClose,
onDone,
}: {
user: ManagedUser;
onClose: () => void;
onDone: () => void;
}) {
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
const [selected, setSelected] = useState<Set<number>>(() => new Set(user.project_ids));
const saveMutation = useMutation({
mutationFn: () => setUserProjects(user.id, [...selected].sort((a, b) => a - b)),
onSuccess: (updated) => {
toast.success(`محدوده دسترسی ${updated.display_name || updated.username} به‌روزرسانی شد`);
onDone();
},
onError: (err) => apiErrorToast(err, "ذخیره پروژه‌ها ناموفق بود"),
});
const toggle = (opProjectId: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(opProjectId)) next.delete(opProjectId);
else next.add(opProjectId);
return next;
});
};
const projects = projectsQuery.data ?? [];
return (
<Modal open title={`پروژه‌های ${user.display_name || user.username}`} onClose={onClose}>
<p className="mb-3 text-[11px] leading-5 text-slate-500">
سرپرست پروژه فقط می‌تواند پروژه‌های انتخاب‌شده را ببیند و روی آن‌ها اجرا ثبت کند. بدون انتخاب، هیچ پروژه‌ای در
دسترس او نیست.
</p>
{projectsQuery.isPending ? (
<div className="space-y-2">
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
) : projectsQuery.isError ? (
<ErrorState message="خطا در دریافت پروژه‌ها" onRetry={() => void projectsQuery.refetch()} />
) : projects.length === 0 ? (
<p className="py-3 text-center text-[11px] text-slate-400">پروژه‌ای در دسترس نیست</p>
) : (
<div className="max-h-72 space-y-2 overflow-y-auto">
{projects.map((p) => {
const checked = selected.has(p.op_project_id);
return (
<button
key={p.op_project_id}
type="button"
onClick={() => toggle(p.op_project_id)}
className={`flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-right text-xs font-medium ${
checked
? "border-sky-300 bg-sky-50 text-sky-800"
: "border-slate-200 text-slate-700 active:bg-slate-100"
}`}
>
<span className="min-w-0 flex-1 truncate">{p.name}</span>
{checked && <Chip tone="sky">انتخاب شده</Chip>}
</button>
);
})}
</div>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className={btnPrimary}
>
{saveMutation.isPending ? "در حال ذخیره..." : "ذخیره"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// ساخت کاربر جدید
// ----------------------------------------
function CreateUserSheet({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("project_supervisor");
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: () =>
createUser({
username: username.trim(),
password,
display_name: displayName.trim(),
role,
}),
onSuccess: (user) => {
toast.success(`کاربر ${user.display_name || user.username} ساخته شد`);
onDone();
},
onError: (err) => apiErrorToast(err, "ساخت کاربر ناموفق بود"),
});
const submit = () => {
setError(null);
if (!username.trim()) return setError("نام کاربری را وارد کنید");
if (password.length < 6) return setError("رمز عبور باید حداقل ۶ نویسه باشد");
createMutation.mutate();
};
return (
<Modal open title="کاربر جدید" onClose={onClose}>
<label htmlFor="nu-username" className="mb-1 block text-[11px] font-medium text-slate-500">
نام کاربری *
</label>
<input
id="nu-username"
dir="ltr"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`${inputCls} text-left`}
/>
<label htmlFor="nu-name" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نام نمایشی
</label>
<input id="nu-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} className={inputCls} />
<label htmlFor="nu-password" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
رمز عبور *
</label>
<input
id="nu-password"
type="password"
dir="ltr"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`${inputCls} text-left`}
/>
<label htmlFor="nu-role" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نقش *
</label>
<select
id="nu-role"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
className={inputCls}
>
{(Object.keys(roleLabels) as Role[]).map((r) => (
<option key={r} value={r}>
{roleLabels[r]}
</option>
))}
</select>
{role === "project_supervisor" && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] leading-5 text-slate-500">
<Plus size={13} className="mt-1 shrink-0 text-slate-400" />
پس از ساخت، پروژه‌های این سرپرست را از دکمه «پروژه‌ها» در فهرست کاربران اختصاص دهید.
</p>
)}
{error && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{error}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={createMutation.isPending} onClick={submit} className={btnPrimary}>
{createMutation.isPending ? "در حال ساخت..." : "ساخت کاربر"}
</button>
</div>
</Modal>
);
};
+90
View File
@@ -0,0 +1,90 @@
@import "tailwindcss";
@theme {
--font-sans: "Vazirmatn", "Tahoma", ui-sans-serif, system-ui, sans-serif;
}
html {
font-family: "Vazirmatn", "Tahoma", ui-sans-serif, system-ui, sans-serif;
}
body {
background-color: var(--color-slate-200);
-webkit-tap-highlight-color: transparent;
}
/* اسلایدر پیشرفت کمی بلندتر برای لمس راحت */
input[type="range"] {
height: 1.75rem;
}
/* ---- اسلایدر زوم گانت (عمودی) ------------------------------------------
قاعدهٔ بالا height همهٔ اسلایدرها را ۱.۷۵rem می‌کند و اسلایدر عمودی را به یک
نوار خیلی کوتاه تبدیل می‌کند؛ پس ارتفاع و ریل اینجا صریح تعیین می‌شود.
نکتهٔ وسط‌چینی دستگیره: در اسلایدر عمودی، محور «عرض» برای دستگیره همان
محور بلوکی است. پس برای این‌که دایره روی ریل وسط بنشیند باید هم عرض ریل و
هم ضخامت دستگیره صریح تعیین شوند و دستگیره با margin در محور بلوکی
وسط‌چین شود. اگر این‌ها را به مرورگر بسپاریم، دستگیره یک‌طرف ریل می‌افتد. */
input[type="range"].zoom-range {
--zoom-track: 0.5rem; /* عرض ریل */
--zoom-thumb: 1.375rem; /* قطر دایره */
--zoom-shift: calc((var(--zoom-thumb) - var(--zoom-track)) / 2);
height: 8rem;
width: var(--zoom-thumb); /* جا برای دایره؛ ریل باریک‌تر است */
writing-mode: vertical-lr;
direction: rtl;
accent-color: var(--color-sky-600);
background: transparent;
/* خود عنصر نباید روی محور بلوکی padding داشته باشد تا جمع عرض‌ها دقیق بماند */
padding: 0;
border: 0;
}
/* ریل: دقیقاً وسط عرض عنصر */
input[type="range"].zoom-range::-webkit-slider-runnable-track {
width: var(--zoom-track);
margin-inline: auto;
border-radius: 9999px;
background: var(--color-slate-200);
}
/* دستگیره: وسط‌چین روی ریل. مرورگر دستگیره را نسبت به لبهٔ ریل می‌چسباند،
پس به اندازهٔ نصف اختلاف، به سمت داخل کشیده می‌شود. */
input[type="range"].zoom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
width: var(--zoom-thumb);
height: var(--zoom-thumb);
margin-block-start: calc(var(--zoom-shift) * 1);
margin-inline-start: calc(var(--zoom-shift) * 1);
border-radius: 9999px;
background: var(--color-sky-600);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgb(15 23 42 / 0.35);
cursor: grab;
}
input[type="range"].zoom-range::-webkit-slider-thumb:active {
cursor: grabbing;
}
input[type="range"].zoom-range::-moz-range-track {
width: var(--zoom-track);
border-radius: 9999px;
background: var(--color-slate-200);
}
input[type="range"].zoom-range::-moz-range-thumb {
box-sizing: border-box;
width: var(--zoom-thumb);
height: var(--zoom-thumb);
margin-inline-start: calc(var(--zoom-shift) * -1);
border-radius: 9999px;
background: var(--color-sky-600);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgb(15 23 42 / 0.35);
cursor: grab;
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
/**
* سازگاری «تاریخ شروع + تاریخ پایان + تعداد روز» — یک قاعده برای همهٔ فرم‌ها.
*
* قاعده: مدت **فاصلهٔ بین دو تاریخ** است و روزِ پایان در آن حساب نمی‌شود،
* یعنی `پایان = شروع + روز`.
* مثال: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
*
* همین قاعده در سرور هم پیاده شده است (`backend/app/utils/date_service.py`:
* `calculate_duration` و `compute_end_date`) و این دو تابع معکوس هم‌اند، پس
* عددی که کاربر وارد می‌کند بعد از ذخیره و بارگذاری مجدد همان می‌ماند.
*
* هر فرمی که این سه فیلد را دارد باید ازین ماژول استفاده کند تا رفتار
* همه‌جا یکسان بماند (فرم تسک/زیرتسک، ویرایش تسک، کشوی گانت).
*
* تصمیم‌های طراحی:
* - هر مقدار ISO که با «تاریخ» کار دارد با `isoToApiDate` به «YYYY-MM-DD»
* تبدیل می‌شود؛ هرگز مستقیم از `new Date(iso)` برای اختلاف روز استفاده
* نکنید (تغییر ساعت تابستانی می‌تواند اختلاف را یک روز جابه‌جا کند).
* - «آخرین فیلدی که کاربر دست زده» تعیین می‌کند کدام مقدار مبنا باشد.
* - شروع و پایان هم‌روز ⇒ مدت صفر (هم‌راستا با سرور).
*/
import { isoToApiDate } from "./jalali";
/** فاصلهٔ دو تاریخ به روز (روز پایان حساب نمی‌شود). */
export function daysBetweenInclusive(
startIso: string | null | undefined,
endIso: string | null | undefined,
): number | null {
const start = isoToApiDate(startIso ?? null);
const end = isoToApiDate(endIso ?? null);
if (!start || !end) return null;
const s = new Date(`${start}T00:00:00`);
const e = new Date(`${end}T00:00:00`);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return null;
// هم‌روز = صفر (مثل سرور)؛ بازهٔ معکوس هم صفر می‌شود
return Math.max(0, Math.round((e.getTime() - s.getTime()) / 86400000));
}
/** تاریخ پایان ISO را از شروع + فاصلهٔ روز می‌سازد. */
export function endFromStartAndDays(
startIso: string | null | undefined,
days: number,
): string | null {
const start = isoToApiDate(startIso ?? null);
if (!start) return null;
const d = new Date(`${start}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
d.setDate(d.getDate() + Math.max(0, days));
return d.toISOString();
}
/** تاریخ شروع ISO را از پایان و فاصلهٔ روز می‌سازد. */
export function startFromEndAndDays(
endIso: string | null | undefined,
days: number,
): string | null {
const end = isoToApiDate(endIso ?? null);
if (!end) return null;
const d = new Date(`${end}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
d.setDate(d.getDate() - Math.max(0, days));
return d.toISOString();
}
/** فیلدی که کاربر آخر از همه تغییر داده — مبناي محاسبهٔ بقیه است. */
export type DateField = "start" | "end" | "days";
export interface DateTriple {
start: string | null;
end: string | null;
days: number | "";
}
/**
* با تغییر هر یک از سه فیلد، دو فیلد دیگر را سازگار می‌کند.
* `field` همان فیلدی است که کاربر تغییر داده و مبنای محاسبه است.
*/
export function reconcileDates(
next: DateTriple,
field: DateField,
): DateTriple {
const hasDays = typeof next.days === "number" && next.days > 0;
if (field === "days") {
if (!hasDays) return next;
const days = next.days as number;
// مبنا: اگر پایان را دست زده بودند همان می‌ماند، وگرنه شروع
if (next.end && !next.start) {
return { ...next, start: startFromEndAndDays(next.end, days) };
}
if (next.start) {
return { ...next, end: endFromStartAndDays(next.start, days) };
}
return next;
}
// تغییر یکی از دو تاریخ: اگر «تعداد روز» وارد شده، آن را مبنای هم‌سانی می‌گیریم
const derived = hasDays
? field === "start"
? { ...next, end: endFromStartAndDays(next.start, next.days as number) }
: { ...next, start: startFromEndAndDays(next.end, next.days as number) }
: next;
// و اگر هر دو تاریخ موجودند، تعداد روز خودش را هم‌سان می‌کند
const computed = daysBetweenInclusive(derived.start, derived.end);
return computed !== null ? { ...derived, days: computed } : derived;
}
/**
* اگر یکی از تاریخ‌ها خالی باشد و تعداد روز وارد شده باشد، آن را پر می‌کند
* (برای فرم‌هایی که روی ذخیره این کار را می‌کنند).
*/
export function fillMissingDate(
startIso: string | null,
endIso: string | null,
days: number | "",
): { start: string | null; end: string | null } {
const hasDays = typeof days === "number" && days > 0;
if (!hasDays) return { start: startIso, end: endIso };
if (startIso && !endIso) return { start: startIso, end: endFromStartAndDays(startIso, days) };
if (endIso && !startIso) return { start: startFromEndAndDays(endIso, days), end: endIso };
return { start: startIso, end: endIso };
}
+274
View File
@@ -0,0 +1,274 @@
import type { Tone } from "../components/ui";
import { isoToJalaliLong, isoToJalaliStr, toFa } from "./jalali";
// ----------------------------------------
// تاریخ و زمان
// ----------------------------------------
/**
* نمایش تاریخ به شکل شمسی: «۱۴۰۵/۰۶/۲۳»
*
* مقایسه‌ها (isOverdue و…) همیشه روی مقدار خام ISO انجام می‌شوند، نه روی این رشته.
*/
export function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const text = isoToJalaliStr(value);
return text || "—";
}
/** نمایش «۲۳ شهریور ۱۴۰۵» — خوانا تر برای زیرنویس‌ها */
export function formatDateLong(value: string | null | undefined): string {
if (!value) return "—";
const text = isoToJalaliLong(value);
return text || "—";
}
export function formatTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return toFa(`${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`);
}
/** تاریخ شمسی + ساعت — مثال: «۱۴۰۵/۰۶/۲۳ ۱۴:۰۵» */
export function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = formatDate(value);
if (date === "—") return "—";
const time = formatTime(value);
return time === "—" ? date : `${date} ${time}`;
}
/** زمان نسبی به سبک فارسی: «۵ دقیقه پیش» / «۳ ساعت دیگر» */
export function relativeTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const diffMs = Date.now() - date.getTime();
const abs = Math.abs(diffMs);
const suffix = diffMs < 0 ? "دیگر" : "پیش";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
if (abs < MINUTE) return "همین حالا";
if (abs < HOUR) return `${Math.round(abs / MINUTE)} دقیقه ${suffix}`;
if (abs < DAY) return `${Math.round(abs / HOUR)} ساعت ${suffix}`;
if (abs < 30 * DAY) return `${Math.round(abs / DAY)} روز ${suffix}`;
return formatDate(value);
}
/** آیا تاریخ سررسید گذشته است؟ */
export function isOverdue(dueDate: string | null | undefined): boolean {
if (!dueDate) return false;
const date = new Date(dueDate);
if (Number.isNaN(date.getTime())) return false;
const today = new Date();
const due = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const now = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return due.getTime() < now.getTime();
}
// ----------------------------------------
// برچسب‌های فارسی
// ----------------------------------------
export function qcStatusMeta(status: string | null | undefined): { label: string; tone: Tone } {
switch (status) {
case "pre_qc_pending":
return { label: "منتظر بازرسی پیش از اجرا", tone: "amber" };
case "pre_qc_failed":
return { label: "بازرسی پیش از اجرا مردود", tone: "red" };
case "ready":
return { label: "آماده اجرا", tone: "sky" };
case "exec_qc_pending":
return { label: "منتظر بازرسی اجرا", tone: "amber" };
case "qc_failed_rework":
return { label: "QC مردود — نیاز به اصلاح", tone: "red" };
case "final_passed":
return { label: "تأیید نهایی", tone: "green" };
case "in_progress":
return { label: "در حال اجرا", tone: "sky" };
case "awaiting_qc":
return { label: "منتظر QC", tone: "amber" };
case "qc_passed":
return { label: "QC قبول", tone: "green" };
case "final_qc":
return { label: "QC نهایی", tone: "amber" };
case "blocked":
return { label: "مسدود", tone: "slate" };
default:
return { label: "بدون QC", tone: "slate" };
}
}
export function taskStatusLabel(status: string | null | undefined): string {
if (!status) return "—";
const key = status.toLowerCase().replace(/[\s-]+/g, "_");
const map: Record<string, string> = {
new: "جدید",
in_progress: "در حال اجرا",
closed: "بسته",
locked: "قفل شده",
rejected: "رد شده",
};
return map[key] ?? status;
}
export function typeLabel(type: string | null | undefined): string {
if (!type) return "—";
const map: Record<string, string> = {
task: "وظیفه",
phase: "فاز",
project: "پروژه",
};
return map[type.toLowerCase()] ?? type;
}
export function priorityMeta(priority: string | null | undefined): { label: string; tone: Tone } | null {
switch ((priority ?? "").toLowerCase()) {
case "high":
return { label: "اولویت بالا", tone: "red" };
case "medium":
return { label: "اولویت متوسط", tone: "amber" };
case "low":
return { label: "اولویت پایین", tone: "slate" };
default:
return null;
}
}
/** سه مرحله QC — «پیش از اجرا» اول می‌آید */
export const qcPhases: Array<{ value: string; label: string }> = [
{ value: "pre", label: "پیش از اجرا" },
{ value: "during", label: "هنگام اجرا" },
{ value: "after", label: "پس از اجرا" },
];
export function phaseLabel(phase: string | null | undefined): string {
switch (phase) {
case "pre":
return "پیش از اجرا";
case "after":
return "پس از اجرا";
case "during":
return "هنگام اجرا";
default:
return phase ?? "—";
}
}
export function phaseTone(phase: string | null | undefined): Tone {
if (phase === "pre") return "amber";
if (phase === "during") return "sky";
return "slate";
}
/** وضعیت محاسبه‌شده در سرور برای یک فاز یا کل QC */
export function qcSummaryStatusMeta(status: string | null | undefined): { label: string; tone: Tone } {
switch (status) {
case "passed":
return { label: "تأیید شده", tone: "green" };
case "failed":
return { label: "مردود", tone: "red" };
case "pending":
return { label: "در انتظار بازرسی", tone: "amber" };
default:
return { label: "بدون سؤال", tone: "slate" };
}
}
export function answerMeta(answer: string | null | undefined): { label: string; tone: Tone } {
switch (answer) {
case "pass":
return { label: "قبول", tone: "green" };
case "fail":
return { label: "مردود", tone: "red" };
case "na":
return { label: "مطرح نیست", tone: "slate" };
default:
return { label: "بدون پاسخ", tone: "amber" };
}
}
export function severityMeta(severity: string): { label: string; tone: Tone } {
switch (severity) {
case "high":
return { label: "شدت زیاد", tone: "red" };
case "medium":
return { label: "شدت متوسط", tone: "amber" };
default:
return { label: "شدت کم", tone: "slate" };
}
}
export function severityLabel(severity: string): string {
switch (severity) {
case "high":
return "زیاد";
case "medium":
return "متوسط";
default:
return "کم";
}
}
/** گزینه‌های شدت عیب (برای بات‌شیت ثبت عیب) */
export const severityOptions: Array<{ value: string; label: string }> = [
{ value: "low", label: "کم" },
{ value: "medium", label: "متوسط" },
{ value: "high", label: "زیاد" },
];
/** گزینه‌های سه‌گانه پاسخ بازرسی */
export const answerOptions: Array<{ value: string; label: string; activeCls: string }> = [
{ value: "pass", label: "قبول", activeCls: "bg-emerald-500 text-white shadow-sm" },
{ value: "fail", label: "مردود", activeCls: "bg-red-500 text-white shadow-sm" },
{ value: "na", label: "مطرح نیست", activeCls: "bg-slate-500 text-white shadow-sm" },
];
export function defectStatusMeta(status: string): { label: string; tone: Tone } {
return status === "resolved" ? { label: "رفع شده", tone: "green" } : { label: "باز", tone: "red" };
}
export function syncDirectionLabel(direction: string): string {
if (direction === "push") return "ارسال";
if (direction === "pull") return "دریافت";
return direction;
}
export function logLevelClass(level: string): string {
switch (level.toLowerCase()) {
case "error":
return "text-red-600";
case "warn":
case "warning":
return "text-amber-600";
default:
return "text-slate-500";
}
}
// ----------------------------------------
// فاز ۲: مصالح و منابع
// ----------------------------------------
/** عدد بدون صفر اضافی: ۲۵۰.۰ → «۲۵۰» */
export function formatQty(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
return String(Math.round(value * 1000) / 1000);
}
/** سطح موجودی محاسبه‌شده در سرور */
export function availabilityMeta(level: string | null | undefined): { label: string; tone: Tone } {
switch (level) {
case "ok":
return { label: "موجود", tone: "green" };
case "partial":
return { label: "کمبود جزیی", tone: "amber" };
case "shortage":
return { label: "ناموجود", tone: "red" };
default:
return { label: "نامشخص", tone: "slate" };
}
}
+175
View File
@@ -0,0 +1,175 @@
// ============================================================
// ابزار تقویم شمسی — پورت jalali.js به TypeScript
// تبدیل‌ها با الگوریتم استاندارد Kazimierz M. Borkowski
// (کپی مستقل برای PM — بدون وابستگی بیرونی)
// ============================================================
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function div(a: number, b: number): number {
return Math.floor(a / b);
}
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
jy = +jy;
jm = +jm;
jd = +jd;
let gy = jy <= 979 ? 621 : 1600;
jy -= jy <= 979 ? 0 : 979;
let days =
365 * jy +
div(jy, 33) * 8 +
div((jy % 33) + 3, 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
gy += 400 * div(days, 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * div(--days, 36524);
if (days === 36524) days = 0;
days++;
}
gy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
gy += div(days - 1, 365);
days = (days - 1) % 365;
}
let gd = days + 1;
const sal_a = [
0,
31,
(gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
for (; gm < 13 && gd > sal_a[gm]; gm++) gd -= sal_a[gm];
return [gy, gm, gd];
}
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
gy = +gy;
gm = +gm;
gd = +gd;
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy = gy <= 1600 ? 0 : 979;
gy -= gy <= 1600 ? 621 : 1600;
let days =
365 * gy +
div(gy + 3, 4) -
div(gy + 99, 100) +
div(gy + 399, 400) -
80 +
gd +
g_d_m[gm - 1];
jy += 33 * div(days, 12053);
days %= 12053;
jy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
jy += div(days - 1, 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return [jy, jm, jd];
}
export function isLeapJalali(jy: number): boolean {
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
const jp = breaks[0];
const jm = breaks[1];
let jump = 0;
let jp2 = jp;
for (const jl of breaks) {
jump = jl - jp2;
if (jy <= jl) break;
jp2 = jl;
}
let n = jy - jm;
if (n < jump) n = n + jump + 1;
return (n % 33) % 4 === 1;
}
// ---------- قالب‌بندی با ارقام فارسی ----------
export function toFa(s: string | number): string {
return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]);
}
/** ارقام فارسی/عربی → لاتین */
export function toEn(s: string | number): string {
return String(s)
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
}
export const MONTHS = [
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
];
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
// تبدیل ISO میلادی به «۱۴۰۳/۰۵/۱۲»
export function isoToJalaliStr(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());
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
}
/** نمایش بلند: «۱۸ مرداد ۱۴۰۴» */
export function isoToJalaliLong(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());
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
}
// تبدیل «۱۴۰۳/۰۵/۱۲» به ISO میلادی (نیمه‌شب محلی)
export function jalaliStrToIso(str: string | null | undefined): string | null {
if (!str) return null;
const nums = toEn(String(str))
.split(/[/\-.]/)
.map((x) => parseInt(x, 10));
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
const [jy, jm, jd] = nums;
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dt = new Date(gy, gm - 1, gd);
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
}
/**
* UI ISO (ممکن است زمان داشته باشد) → «YYYY-MM-DD» بر مبنای تقویم محلی
* (همان چیزی که API برای فیلدهای تاریخ‌محور انتظار دارد).
*
* مهم: هرگز از `iso.slice(0, 10)` استفاده نکنید — `new Date(y, m, d).toISOString()`
* در UTC+3:30 روز میلادی قبلی را برمی‌گرداند.
*/
export function isoToApiDate(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${mo}-${day}`;
}
export function daysInMonth(jy: number, jm: number): number {
if (jm <= 6) return 31;
if (jm <= 11) return 30;
return isLeapJalali(jy) ? 30 : 29;
}
+78
View File
@@ -0,0 +1,78 @@
import type { LucideIcon } from "lucide-react";
import { ClipboardCheck, Folder, Home, Settings } from "lucide-react";
import type { Role, User } from "./api";
/**
* آینه سمت کلاینت از مجوزهای بک‌اند (`backend/app/authz.py`).
*
* مرجع نهایی همان `user.permissions` است که سرور برمی‌گرداند؛ ثابت‌های زیر فقط
* برای اشاره به نام مجوزها و ساخت ناوبری به کار می‌روند. سرور مستقل از این
* فایل، همه عملیات را دوباره بررسی می‌کند؛ پنهان‌کردن دکمه هرگز جایگزین
* کنترل دسترسی نیست.
*/
export const Perm = {
dashboardView: "dashboard.view",
projectsView: "projects.view",
projectsCreate: "projects.create",
projectsUpdate: "projects.update",
tasksView: "tasks.view",
tasksCreate: "tasks.create",
tasksUpdate: "tasks.update",
tasksDelete: "tasks.delete",
tasksProgress: "tasks.progress",
tasksDates: "tasks.dates",
qcView: "qc.view",
qcExecute: "qc.execute",
qcDefine: "qc.define",
materialsView: "materials.view",
materialsManage: "materials.manage",
usersManage: "users.manage",
filesUpload: "files.upload",
} as const;
export type Permission = (typeof Perm)[keyof typeof Perm];
export const roleLabels: Record<Role, string> = {
project_manager: "مدیر پروژه",
project_supervisor: "سرپرست پروژه",
};
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
return user?.permissions?.includes(permission) ?? false;
}
export function isManager(user: User | null | undefined): boolean {
return user?.role === "project_manager";
}
/** برچسب کوتاه محدوده دسترسی — برای هدر داشبورد و کارت کاربر */
export function scopeLabel(user: User | null | undefined): string {
if (!user) return "";
if (user.scope?.all_projects) return "همه پروژه‌ها";
const count = user.scope?.project_count ?? 0;
return count > 0 ? `${count} پروژه اختصاص‌یافته` : "بدون پروژه اختصاص‌یافته";
}
// ----------------------------------------
// ناوبری بر پایه مجوز — تب‌ها خودکار با نقش عوض می‌شوند
// ----------------------------------------
export interface NavTab {
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
/** فقط برای کاربری نمایش داده می‌شود که این مجوز را داشته باشد */
permission: Permission;
}
export const navTabs: NavTab[] = [
{ to: "/", label: "امروز", icon: Home, end: true, permission: Perm.dashboardView },
{ to: "/projects", label: "پروژه‌ها", icon: Folder, permission: Perm.projectsView },
{ to: "/quality", label: "کیفیت", icon: ClipboardCheck, permission: Perm.qcDefine },
{ to: "/settings", label: "تنظیمات", icon: Settings, permission: Perm.dashboardView },
];
export function visibleTabs(user: User | null | undefined): NavTab[] {
return navTabs.filter((tab) => hasPermission(user, tab.permission));
}
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster position="top-center" dir="rtl" richColors closeButton />
</QueryClientProvider>
</React.StrictMode>,
);
+95
View File
@@ -0,0 +1,95 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ClipboardCheck } from "lucide-react";
import { ApiError, login } from "../lib/api";
import { useAuthStore } from "../store/auth";
import { PhoneShell, btnPrimary, inputCls } from "../components/ui";
export default function LoginPage() {
const navigate = useNavigate();
const token = useAuthStore((s) => s.token);
const setAuth = useAuthStore((s) => s.setAuth);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const loginMutation = useMutation({
mutationFn: () => login(username.trim(), password),
onSuccess: (data) => {
setAuth(data.token, data.user);
toast.success(`خوش آمدید، ${data.user.display_name || data.user.username}`);
navigate("/", { replace: true });
},
onError: (err) => {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود");
},
});
if (token) return <Navigate to="/" replace />;
const onSubmit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!username.trim() || !password) {
setError("نام کاربری و رمز عبور را وارد کنید");
return;
}
loginMutation.mutate();
};
return (
<PhoneShell>
<div className="flex min-h-dvh flex-col justify-center px-6 py-10">
<div className="mb-8 flex flex-col items-center gap-3 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-sky-600 text-white shadow-lg shadow-sky-200">
<ClipboardCheck size={30} />
</div>
<div>
<h1 className="text-xl font-extrabold text-slate-800">PM</h1>
<p className="mt-1 text-xs text-slate-500">سامانه مدیریت عملیاتی پروژه</p>
</div>
</div>
<form onSubmit={onSubmit} className="rounded-2xl border border-slate-200/70 bg-white p-5 shadow-sm">
<label htmlFor="username" className="mb-1.5 block text-xs font-semibold text-slate-600">
نام کاربری
</label>
<input
id="username"
dir="ltr"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`${inputCls} text-left`}
placeholder="username"
/>
<label htmlFor="password" className="mb-1.5 mt-4 block text-xs font-semibold text-slate-600">
رمز عبور
</label>
<input
id="password"
type="password"
dir="ltr"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`${inputCls} text-left`}
placeholder="••••••••"
/>
{error && <p className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs leading-5 text-red-600">{error}</p>}
<button type="submit" disabled={loginMutation.isPending} className={`${btnPrimary} mt-5 w-full`}>
{loginMutation.isPending ? "در حال ورود..." : "ورود"}
</button>
</form>
<p className="mt-8 text-center text-[11px] text-slate-400">PM v0.1</p>
</div>
</PhoneShell>
);
}
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Folder, Plus, RefreshCw, Users, MapPin, Calendar } from "lucide-react";
import { createProject, getProjects } from "../lib/api";
import type { CreateProjectPayload, Project } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
PanelsMenu,
ProgressBar,
btnPrimary,
btnSecondary,
inputCls,
} from "../components/ui";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
function ProjectCard({ project }: { project: Project }) {
const targetId = project.id || project.op_project_id;
const statusLabels: Record<string, { label: string; tone: "green" | "sky" | "amber" | "slate" }> = {
PLANNING: { label: "برنامه‌ریزی", tone: "amber" },
PLANNED: { label: "برنامه‌ریزی", tone: "amber" },
ACTIVE: { label: "در حال اجرا", tone: "green" },
ON_HOLD: { label: "متوقف", tone: "amber" },
COMPLETED: { label: "تکمیل‌شده", tone: "sky" },
CANCELLED: { label: "لغو شده", tone: "slate" },
ARCHIVED: { label: "بایگانی", tone: "slate" },
};
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
return (
<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>
</div>
</div>
{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>
)}
{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>
<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>
</Card>
</Link>
);
}
function CreateProjectModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [projectManager, setProjectManager] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const createMutation = useMutation({
mutationFn: (payload: CreateProjectPayload) => createProject(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
setName("");
setDescription("");
setLocation("");
setProjectManager("");
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ثبت پروژه");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setErrorMsg("نام پروژه الزامی است");
return;
}
setErrorMsg("");
createMutation.mutate({
name: name.trim(),
description: description.trim(),
location: location.trim(),
project_manager: projectManager.trim(),
});
};
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>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثال: برج مسکونی آسمان"
required
className={inputCls}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">محل پروژه</label>
<input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="موقعیت جغرافیایی / شهر"
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">مدیر / سرپرست پروژه</label>
<input
value={projectManager}
onChange={(e) => setProjectManager(e.target.value)}
placeholder="نام سرپرست"
className={inputCls}
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">توضیحات</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="توضیحات تکمیلی پروژه..."
className="w-full rounded-xl border border-slate-300 p-2.5 text-sm text-slate-800 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
{createMutation.isPending ? "در حال ثبت..." : "ایجاد پروژه"}
</button>
</div>
</form>
</Modal>
);
}
export default function ProjectsPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canCreate = !isSupervisor && hasPermission(user, Perm.projectsCreate);
const [showCreateModal, setShowCreateModal] = useState(false);
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
};
return (
<>
<PageHeader
title="پروژه‌ها"
menu={<PanelsMenu />}
right={
<div className="flex items-center gap-1">
{canCreate && (
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white transition-colors active:bg-sky-700"
>
<Plus size={16} />
<span>پروژه جدید</span>
</button>
)}
<button
type="button"
onClick={refresh}
aria-label="به‌روزرسانی"
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
>
<RefreshCw size={20} className={projectsQuery.isFetching ? "animate-spin" : ""} />
</button>
</div>
}
/>
<div className="px-4 pb-28 pt-4">
{projectsQuery.isPending ? (
<div className="space-y-3">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
) : projectsQuery.isError ? (
<ErrorState onRetry={refresh} />
) : (
<>
{(projectsQuery.data ?? []).length === 0 ? (
<Card>
<EmptyState
icon={Folder}
title={canCreate ? "پروژه‌ای ثبت نشده است" : "پروژه‌ای به شما اختصاص داده نشده است"}
description={
canCreate
? "برای شروع کار، روی دکمه «پروژه جدید» در بالا بزنید."
: "در حال حاضر هیچ پروژه‌ای به شما تخصیص نیافته است. مدیر پروژه باید از بخش کاربران دسترسی شما را تعریف کند."
}
/>
</Card>
) : (
<div className="space-y-3">
{(projectsQuery.data ?? []).map((p) => (
<ProjectCard key={p.id || p.op_project_id} project={p} />
))}
</div>
)}
</>
)}
</div>
<CreateProjectModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
</>
);
}
+956
View File
@@ -0,0 +1,956 @@
import { useRef, useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ClipboardCheck,
ExternalLink,
Layers,
Pencil,
Plus,
Search,
ShieldAlert,
Trash2,
Wrench,
X,
} from "lucide-react";
import {
createQcTemplate,
deleteQcQuestion,
deleteQcTemplate,
getDefects,
getQcTemplates,
replaceQcTemplateQuestions,
resolveDefect,
updateQcTemplate,
} from "../lib/api";
import type { Defect, QcPhase, QcQuestionInput, QcTemplate, QcTemplateQuestion } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
PanelsMenu,
btnPrimary,
btnSecondary,
inputCls,
ConfirmSheet,
apiErrorToast,
} from "../components/ui";
import { defectStatusMeta, formatDate, phaseTone, qcPhases, relativeTime, severityMeta } from "../lib/format";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
// ----------------------------------------
// شناسه یکتای پایدار برای ردیف‌های ویرایشگر
// ----------------------------------------
let keyCounter = 0;
function newRowKey(): string {
keyCounter += 1;
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `row-${keyCounter}-${Date.now()}`;
}
interface DraftQuestion {
key: string;
phase: QcPhase;
text: string;
required: boolean;
}
function draftFrom(questions: QcTemplateQuestion[]): DraftQuestion[] {
return questions.map((q) => ({
key: newRowKey(),
phase: q.phase,
text: q.text,
required: q.required,
}));
}
function toPayload(rows: DraftQuestion[]): QcQuestionInput[] {
const out: QcQuestionInput[] = [];
for (const phase of qcPhases) {
const inPhase = rows.filter((r) => r.phase === phase.value && r.text.trim());
inPhase.forEach((r, index) => {
out.push({ phase: r.phase, text: r.text.trim(), required: r.required, sort_order: index });
});
}
return out;
}
// ----------------------------------------
// ویرایشگر قالب QC
// ----------------------------------------
function QcTemplateEditor({
template,
onClose,
}: {
template: QcTemplate | null;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(() => template?.name ?? "");
const [activityType, setActivityType] = useState(() => template?.activity_type ?? "");
const [description, setDescription] = useState(() => template?.description ?? "");
const [rows, setRows] = useState<DraftQuestion[]>(() => {
const initial = template ? draftFrom(template.questions) : [];
return initial.length > 0 ? initial : [{ key: newRowKey(), phase: "pre", text: "", required: true }];
});
const [formError, setFormError] = useState<string | null>(null);
const patchRow = (key: string, patch: Partial<DraftQuestion>) => {
setRows((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row)));
};
const addRow = (phase: QcPhase = "pre") => {
setRows((prev) => [...prev, { key: newRowKey(), phase, text: "", required: true }]);
};
const removeRow = (key: string) => {
setRows((prev) => prev.filter((row) => row.key !== key));
};
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
const createMutation = useMutation({
mutationFn: (payload: { name: string; activity_type: string; description: string; questions: QcQuestionInput[] }) =>
createQcTemplate(payload),
onSuccess: () => {
invalidate();
onClose();
},
onError: (err) => apiErrorToast(err, "ایجاد قالب ناموفق بود"),
});
const updateMutation = useMutation({
mutationFn: async (payload: {
name: string;
activity_type: string;
description: string;
questions: QcQuestionInput[];
}) => {
if (!template) return;
await updateQcTemplate(template.id, {
name: payload.name,
activity_type: payload.activity_type,
description: payload.description,
});
await replaceQcTemplateQuestions(template.id, payload.questions);
},
onSuccess: () => {
invalidate();
onClose();
},
onError: (err) => apiErrorToast(err, "ذخیره قالب ناموفق بود"),
});
const pending = createMutation.isPending || updateMutation.isPending;
const submit = () => {
setFormError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setFormError("نام قالب را وارد کنید");
return;
}
const questions = toPayload(rows);
if (questions.length === 0) {
setFormError("حداقل متن یک سؤال را وارد کنید");
return;
}
const payload = {
name: trimmedName,
activity_type: activityType.trim(),
description: description.trim(),
questions,
};
if (template) updateMutation.mutate(payload);
else createMutation.mutate(payload);
};
const grouped = qcPhases.map((phase) => ({
phase: phase.value as QcPhase,
label: phase.label,
rows: rows.filter((r) => r.phase === phase.value),
}));
return (
<Modal open title={template ? `ویرایش «${template.name}»` : "قالب جدید بازرسی کیفی"} onClose={onClose}>
<label htmlFor="qc-name" className="mb-1 block text-[11px] font-medium text-slate-500">
نام قالب *
</label>
<input id="qc-name" value={name} onChange={(e) => setName(e.target.value)} className={inputCls} placeholder="مثال: کنترل کیفیت اجرای فونداسیون" />
<label htmlFor="qc-activity" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نوع فعالیت / دسته
</label>
<input
id="qc-activity"
value={activityType}
onChange={(e) => setActivityType(e.target.value)}
className={inputCls}
placeholder="مثلاً: سازه، تأسیسات، نازک‌کاری"
/>
<label htmlFor="qc-desc" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
توضیحات و دستورالعمل
</label>
<textarea
id="qc-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
placeholder="توضیحات تکمیلی یا مراجع آیین‌نامه‌ای..."
/>
<p className="mb-1.5 mt-4 text-[11px] font-medium text-slate-500">سؤالات و چک‌لیست مراحل *</p>
<div className="space-y-4 max-h-[50vh] overflow-y-auto px-1">
{grouped.map((group) => (
<div key={group.phase} className="rounded-xl border border-slate-200/80 bg-slate-50/50 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Chip tone={phaseTone(group.phase)}>{group.label}</Chip>
<button
type="button"
onClick={() => addRow(group.phase)}
className="inline-flex h-7 items-center gap-1 rounded-lg bg-white border border-slate-200 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-50 shadow-sm"
>
<Plus size={12} />
افزودن سؤال
</button>
</div>
{group.rows.length === 0 ? (
<p className="py-1 text-[11px] text-slate-400">سؤالی در این مرحله نیست</p>
) : (
<div className="space-y-2">
{group.rows.map((row, indexInPhase) => (
<div key={row.key} className="flex items-center gap-2">
<input
value={row.text}
onChange={(e) => patchRow(row.key, { text: e.target.value })}
placeholder={`متن بازرسی ${indexInPhase + 1}`}
className={`${inputCls} h-9 text-xs flex-1`}
/>
<button
type="button"
onClick={() => removeRow(row.key)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-9 w-8 shrink-0 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
)}
</div>
))}
</div>
{formError && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{formError}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending} onClick={submit} className={btnPrimary}>
{pending ? "در حال ذخیره..." : template ? "ذخیره تغییرات" : "ایجاد قالب"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// جزئیات قالب بازرسی
// ----------------------------------------
function TemplateQuestions({ template }: { template: QcTemplate }) {
const [deleteTarget, setDeleteTarget] = useState<QcTemplateQuestion | null>(null);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (questionId: string) => deleteQcQuestion(questionId),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
},
onError: (err) => apiErrorToast(err, "حذف سؤال ناموفق بود"),
});
return (
<>
<div className="space-y-3 pt-1">
{qcPhases.map((phase) => {
const questions = template.questions.filter((q) => q.phase === phase.value);
if (questions.length === 0) return null;
return (
<div key={phase.value} className="rounded-lg border border-slate-100 bg-slate-50/70 p-2.5">
<div className="mb-1 flex items-center justify-between">
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<span className="text-[10px] text-slate-400 font-medium">{questions.length} بازرسی</span>
</div>
<div className="mt-1 divide-y divide-slate-200/60">
{questions.map((q) => (
<div key={q.id} className="flex items-start justify-between gap-2 py-1.5">
<p className="min-w-0 flex-1 text-xs leading-5 text-slate-700">{q.text}</p>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => setDeleteTarget(q)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
{deleteTarget && (
<ConfirmSheet
open
title="حذف سؤال از قالب"
message={`آیا سؤال «${deleteTarget.text}» از این قالب حذف شود؟`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
// ----------------------------------------
// مودال ثبت رفع عدم انطباق (Defect Resolution Modal)
// ----------------------------------------
function DefectResolutionModal({
defect,
onClose,
onResolved,
}: {
defect: Defect;
onClose: () => void;
onResolved: () => void;
}) {
const [notes, setNotes] = useState("");
const resolveMutation = useMutation({
mutationFn: () => resolveDefect(defect.id, notes),
onSuccess: () => {
onResolved();
onClose();
},
onError: (err) => apiErrorToast(err, "ثبت رفع عیب ناموفق بود"),
});
return (
<Modal open title="ثبت رفع عدم انطباق" onClose={onClose}>
<div className="space-y-3">
<div className="rounded-xl border border-slate-200 bg-slate-50/80 p-3">
<span className="text-[11px] font-semibold text-slate-500 block mb-0.5">موضوع عدم انطباق:</span>
<p className="text-xs font-bold text-slate-800">{defect.title}</p>
{defect.task_title && (
<p className="text-[11px] text-slate-500 mt-1">وظیفه: {defect.task_title}</p>
)}
</div>
<div>
<label htmlFor="defect-resolution-notes" className="mb-1 block text-xs font-medium text-slate-600">
اقدامات انجام‌شده و توضیحات رفع عیب *
</label>
<textarea
id="defect-resolution-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className={`${inputCls} h-auto py-2`}
placeholder="شرح اقدامات اصلاحی صورت گرفته جهت رفع مشکل..."
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={resolveMutation.isPending}
onClick={() => resolveMutation.mutate()}
className={btnPrimary}
>
{resolveMutation.isPending ? "در حال ثبت..." : "تأیید و رفع عیب"}
</button>
</div>
</div>
</Modal>
);
}
// ----------------------------------------
// تابلوی جامع عدم انطباق‌ها و عیوب (Defects Board)
// ----------------------------------------
function DefectsBoard({ canExecuteQc }: { canExecuteQc: boolean }) {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all");
const [severityFilter, setSeverityFilter] = useState<"all" | "high" | "medium" | "low">("all");
const [search, setSearch] = useState("");
const [activeDefectToResolve, setActiveDefectToResolve] = useState<Defect | null>(null);
const defectsQuery = useQuery({
queryKey: ["qc-defects", statusFilter === "all" ? undefined : statusFilter],
queryFn: () => getDefects(statusFilter === "all" ? undefined : { status: statusFilter }),
});
const allDefects = defectsQuery.data ?? [];
// محاسبه آمار سریع
const stats = useMemo(() => {
const total = allDefects.length;
const open = allDefects.filter((d) => d.status !== "resolved").length;
const resolved = allDefects.filter((d) => d.status === "resolved").length;
const high = allDefects.filter((d) => d.severity === "high" && d.status !== "resolved").length;
return { total, open, resolved, high };
}, [allDefects]);
// فیلتر کلاینت برای جستجو و شدت
const filteredDefects = useMemo(() => {
return allDefects.filter((d) => {
if (severityFilter !== "all" && d.severity !== severityFilter) return false;
if (search.trim()) {
const query = search.trim().toLowerCase();
const matchesTitle = d.title.toLowerCase().includes(query);
const matchesDesc = (d.description ?? "").toLowerCase().includes(query);
const matchesAction = (d.corrective_action ?? "").toLowerCase().includes(query);
const matchesTask = (d.task_title ?? "").toLowerCase().includes(query);
if (!matchesTitle && !matchesDesc && !matchesAction && !matchesTask) return false;
}
return true;
});
}, [allDefects, severityFilter, search]);
const refreshDefects = () => {
void queryClient.invalidateQueries({ queryKey: ["qc-defects"] });
void queryClient.invalidateQueries({ queryKey: ["task-qc"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
return (
<div className="space-y-4">
{/* ردیف کارت‌های خلاصه وضعیت */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
<span className="text-[11px] text-slate-500 font-medium block">کل موارد ثبت‌شده</span>
<span className="text-lg font-bold text-slate-800 tabular-nums">{stats.total}</span>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 shadow-xs">
<span className="text-[11px] text-amber-700 font-medium block">در انتظار رفع (باز)</span>
<span className="text-lg font-bold text-amber-800 tabular-nums">{stats.open}</span>
</div>
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 shadow-xs">
<span className="text-[11px] text-emerald-700 font-medium block">رفع‌شده</span>
<span className="text-lg font-bold text-emerald-800 tabular-nums">{stats.resolved}</span>
</div>
<div className="rounded-xl border border-red-200 bg-red-50/60 p-3 shadow-xs">
<span className="text-[11px] text-red-700 font-medium block">موارد بحرانی باز</span>
<span className="text-lg font-bold text-red-800 tabular-nums">{stats.high}</span>
</div>
</div>
{/* نوار جستجو و فیلترها */}
<div className="flex flex-col sm:flex-row gap-2.5 items-stretch sm:items-center justify-between">
<div className="relative flex-1">
<Search size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجو در موضوع، شرح عیب، اقدام اصلاحی یا وظیفه..."
className={`${inputCls} pr-8.5 h-9 text-xs`}
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<X size={13} />
</button>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{/* فیلتر وضعیت */}
<div className="flex rounded-lg bg-slate-200/70 p-0.5 text-xs font-medium text-slate-600">
<button
type="button"
onClick={() => setStatusFilter("all")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "all" ? "bg-white text-slate-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
همه ({stats.total})
</button>
<button
type="button"
onClick={() => setStatusFilter("open")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "open" ? "bg-white text-amber-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
باز ({stats.open})
</button>
<button
type="button"
onClick={() => setStatusFilter("resolved")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "resolved" ? "bg-white text-emerald-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
رفع‌شده ({stats.resolved})
</button>
</div>
{/* فیلتر شدت */}
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as any)}
className="h-8.5 rounded-lg border border-slate-200 bg-white px-2 text-xs font-medium text-slate-700 shadow-xs outline-hidden"
>
<option value="all">تمام شدت‌ها</option>
<option value="high">بحرانی (زیاد)</option>
<option value="medium">متوسط</option>
<option value="low">جزئی (کم)</option>
</select>
</div>
</div>
{/* لیست عدم انطباق‌ها */}
{defectsQuery.isPending ? (
<CardSkeleton />
) : defectsQuery.isError ? (
<Card>
<ErrorState onRetry={() => void defectsQuery.refetch()} />
</Card>
) : filteredDefects.length === 0 ? (
<Card>
<EmptyState
icon={CheckCircle2}
title="هیچ عدم انطباقی یافت نشد"
description={
search || severityFilter !== "all" || statusFilter !== "all"
? "با فیلترهای فعلی موردی یافت نشد؛ فیلترها را بازنشانی کنید"
: "تمام بازرسی‌ها بدون اشکال انجام شده و عدم انطباق ثبت‌شده‌ای وجود ندارد"
}
/>
</Card>
) : (
<div className="space-y-2.5">
{filteredDefects.map((defect) => {
const isResolved = defect.status === "resolved";
const sev = severityMeta(defect.severity);
const stMeta = defectStatusMeta(defect.status);
return (
<div
key={defect.id}
className={`rounded-xl border p-3.5 transition-all shadow-xs ${
isResolved
? "border-slate-200 bg-white opacity-85"
: defect.severity === "high"
? "border-red-200 bg-red-50/20"
: "border-slate-200 bg-white"
}`}
>
{/* سرتیتر کارت */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-xs font-bold text-slate-900">{defect.title}</h3>
<Chip tone={stMeta.tone}>{stMeta.label}</Chip>
<Chip tone={sev.tone}>{sev.label}</Chip>
</div>
{/* وظیفه مرتبط */}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<Layers size={13} className="text-slate-400 shrink-0" />
<span>وظیفه:</span>
<Link
to={`/tasks/${defect.task_id || defect.task_op_wp_id}`}
className="font-semibold text-sky-700 hover:text-sky-800 hover:underline flex items-center gap-1"
>
<span>{defect.task_title || `تسک #${defect.task_op_wp_id ?? ""}`}</span>
<ExternalLink size={10} />
</Link>
</div>
</div>
<span className="text-[10px] text-slate-400 shrink-0 font-medium">
{relativeTime(defect.created_at)}
</span>
</div>
{/* شرح عیب */}
{defect.description && (
<p className="mt-2 text-xs leading-5 text-slate-600 bg-slate-50/60 rounded-lg p-2 border border-slate-100">
{defect.description}
</p>
)}
{/* اقدام اصلاحی */}
{defect.corrective_action && (
<div className="mt-2 flex items-start gap-1.5 rounded-lg border border-amber-200/80 bg-amber-50/50 p-2 text-xs text-amber-900">
<Wrench size={14} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold text-[11px] text-amber-800 block">اقدام اصلاحی لازم:</span>
<span className="text-[11px] leading-5">{defect.corrective_action}</span>
</div>
</div>
)}
{/* تصویر پیوست */}
{defect.photo_url && (
<div className="mt-2.5">
<a
href={defect.photo_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-sky-700 hover:underline font-medium"
>
<img
src={defect.photo_url}
alt="تصویر عدم انطباق"
className="h-14 w-14 rounded-lg object-cover border border-slate-200 shadow-xs"
/>
<span>مشاهده تصویر مستند</span>
</a>
</div>
)}
{/* وضعیت رفع یا دکمه اقدام */}
<div className="mt-3 pt-2.5 border-t border-slate-100 flex items-center justify-between gap-2 flex-wrap">
{isResolved ? (
<div className="flex items-center gap-1.5 text-[11px] text-emerald-700 font-medium">
<CheckCircle2 size={15} className="text-emerald-600 shrink-0" />
<span>
رفع‌شده در تاریخ {formatDate(defect.resolved_at)}
{defect.resolution_notes ? ` — ${defect.resolution_notes}` : ""}
</span>
</div>
) : (
<>
<div className="flex items-center gap-1.5 text-[11px] text-amber-700 font-medium">
<AlertTriangle size={14} className="text-amber-600 shrink-0" />
<span>این مورد باز است و مانع تأیید نهایی یا شروع کار می‌شود</span>
</div>
{canExecuteQc && (
<button
type="button"
onClick={() => setActiveDefectToResolve(defect)}
className="inline-flex h-7.5 items-center gap-1.5 rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white hover:bg-emerald-700 transition-colors shadow-xs"
>
<CheckCircle2 size={13} />
<span>ثبت رفع عیب</span>
</button>
)}
</>
)}
</div>
</div>
);
})}
</div>
)}
{activeDefectToResolve && (
<DefectResolutionModal
defect={activeDefectToResolve}
onClose={() => setActiveDefectToResolve(null)}
onResolved={refreshDefects}
/>
)}
</div>
);
}
// ----------------------------------------
// صفحه اصلی کنترل کیفیت (Quality Page)
// ----------------------------------------
export default function QualityPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
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;
const [activeTab, setActiveTab] = useState<"templates" | "defects">("templates");
const [templateSearch, setTemplateSearch] = useState("");
const [expandedTemplateId, setExpandedTemplateId] = useState<string | null>(null);
const [editor, setEditor] = useState<{ template: QcTemplate | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<QcTemplate | null>(null);
const editorKey = useRef(0);
// واکشی قالب‌ها
const qcQuery = useQuery({ queryKey: ["qc-templates"], queryFn: getQcTemplates });
// واکشی تعداد عیوب باز برای بج ناوبری
const openDefectsQuery = useQuery({
queryKey: ["qc-defects", "open"],
queryFn: () => getDefects({ status: "open" }),
});
const deleteMutation = useMutation({
mutationFn: (templateId: string) => deleteQcTemplate(templateId),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
},
onError: (err) => apiErrorToast(err, "حذف قالب ناموفق بود"),
});
const templates = qcQuery.data ?? [];
const openDefectsCount = openDefectsQuery.data?.length ?? 0;
const filteredTemplates = useMemo(() => {
if (!templateSearch.trim()) return templates;
const q = templateSearch.trim().toLowerCase();
return templates.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.activity_type ?? "").toLowerCase().includes(q) ||
(t.description ?? "").toLowerCase().includes(q)
);
}, [templates, templateSearch]);
const openEditor = (template: QcTemplate | null) => {
editorKey.current += 1;
setEditor({ template });
};
return (
<>
<PageHeader title="کنترل کیفیت (QC)" menu={<PanelsMenu />} />
<div className="space-y-4 px-4 pb-28 pt-4">
{/* نوار ناوبری دو تب اصلی */}
<div className="flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600 shadow-inner">
<button
type="button"
onClick={() => setActiveTab("templates")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg py-2 transition-all ${
activeTab === "templates"
? "bg-white text-slate-800 shadow-sm font-bold"
: "hover:text-slate-900"
}`}
>
<ClipboardCheck size={16} className={activeTab === "templates" ? "text-sky-600" : "text-slate-400"} />
<span>قالب‌های استاندارد بازرسی</span>
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600 font-bold tabular-nums">
{templates.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("defects")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg py-2 transition-all ${
activeTab === "defects"
? "bg-white text-slate-800 shadow-sm font-bold"
: "hover:text-slate-900"
}`}
>
<ShieldAlert size={16} className={activeTab === "defects" ? "text-amber-600" : "text-slate-400"} />
<span>تابلوی عدم انطباق‌ها و عیوب</span>
{openDefectsCount > 0 && (
<span className="rounded-full bg-amber-500 text-white px-1.5 py-0.5 text-[10px] font-bold tabular-nums shadow-xs">
{openDefectsCount}
</span>
)}
</button>
</div>
{/* محتوای تب ۱: قالب‌های بازرسی کیفی */}
{activeTab === "templates" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h2 className="text-xs font-bold text-slate-800">قالب‌های چک‌لیست QC</h2>
<p className="text-[11px] text-slate-400 mt-0.5">
قالب‌های از پیش تعریف‌شده برای استانداردسازی بازرسی وظایف عمرانی
</p>
</div>
{canDefineQc && (
<button
type="button"
onClick={() => openEditor(null)}
className="inline-flex h-8.5 items-center gap-1.5 rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white hover:bg-sky-700 transition-colors shadow-xs"
>
<Plus size={14} />
<span>قالب جدید</span>
</button>
)}
</div>
{/* جستجو در قالب‌ها */}
{templates.length > 3 && (
<div className="relative">
<Search size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={templateSearch}
onChange={(e) => setTemplateSearch(e.target.value)}
placeholder="جستجو بر اساس نام قالب یا نوع فعالیت..."
className={`${inputCls} pr-8.5 h-8.5 text-xs`}
/>
</div>
)}
{qcQuery.isPending ? (
<CardSkeleton />
) : qcQuery.isError ? (
<Card>
<ErrorState onRetry={() => void qcQuery.refetch()} />
</Card>
) : filteredTemplates.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardCheck}
title="قالبی یافت نشد"
description={
templateSearch
? "با عبارت جستجو شده قالبی پیدا نشد"
: "با دکمه «قالب جدید» اولین چک‌لیست استاندارد کیفیت را بسازید"
}
/>
</Card>
) : (
<div className="space-y-2">
{filteredTemplates.map((t) => {
const expanded = expandedTemplateId === t.id;
const preCount = t.questions.filter((q) => q.phase === "pre").length;
const duringCount = t.questions.filter((q) => q.phase === "during").length;
const afterCount = t.questions.filter((q) => q.phase === "after").length;
return (
<Card key={t.id} className="p-0 overflow-hidden border-slate-200/90 shadow-xs">
<button
type="button"
onClick={() => setExpandedTemplateId(expanded ? null : t.id)}
className="flex w-full items-center justify-between gap-3 p-3.5 text-right hover:bg-slate-50/60 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="block truncate text-xs font-bold text-slate-900">{t.name}</span>
{t.activity_type && <Chip tone="slate">{t.activity_type}</Chip>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px]">
<Chip tone="sky">{t.questions.length} سؤال</Chip>
{preCount > 0 && <Chip tone="amber">{preCount} پیش از اجرا</Chip>}
{duringCount > 0 && <Chip tone="sky">{duringCount} حین اجرا</Chip>}
{afterCount > 0 && <Chip tone="slate">{afterCount} پس از اجرا</Chip>}
</div>
</div>
{expanded ? (
<ChevronDown size={16} className="shrink-0 text-slate-400" />
) : (
<ChevronLeft size={16} className="shrink-0 text-slate-400" />
)}
</button>
{expanded && (
<div className="border-t border-slate-100 bg-slate-50/30 px-3.5 py-3">
{t.description && (
<p className="pb-2 text-[11px] leading-5 text-slate-600">{t.description}</p>
)}
{t.questions.length === 0 ? (
<p className="py-2 text-xs text-slate-400">سؤالی برای این قالب ثبت نشده است</p>
) : (
<TemplateQuestions template={t} />
)}
<div className="mt-3 pt-2.5 border-t border-slate-200/60 flex items-center justify-between gap-2 flex-wrap">
<p className="text-[10px] text-slate-400">
بارگذاری این قالب در وظیفه‌ها، یک نسخهٔ مستقل می‌سازد و ویرایش قالب روی کارهای جاری اثر ندارد.
</p>
{canDefineQc && (
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => openEditor(t)}
className="inline-flex h-7.5 items-center gap-1 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-xs"
>
<Pencil size={12} />
<span>ویرایش قالب</span>
</button>
<button
type="button"
onClick={() => setDeleteTarget(t)}
className="inline-flex h-7.5 items-center gap-1 rounded-lg border border-red-200 bg-red-50/50 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 size={12} />
<span>حذف</span>
</button>
</div>
)}
</div>
</div>
)}
</Card>
);
})}
</div>
)}
</div>
)}
{/* محتوای تب ۲: تابلوی عدم انطباق‌ها و عیوب */}
{activeTab === "defects" && <DefectsBoard canExecuteQc={canExecuteQc} />}
</div>
{editor && (
<QcTemplateEditor
key={`qc-editor-${editorKey.current}`}
template={editor.template}
onClose={() => setEditor(null)}
/>
)}
{deleteTarget && (
<ConfirmSheet
open
title="حذف قالب بازرسی کیفی"
message={`آیا قالب «${deleteTarget.name}» با ${deleteTarget.questions.length} سؤال حذف شود؟ این کار روی بازرسی‌های ساخته‌شده از آن در وظیفه‌ها اثری ندارد.`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useNavigate } from "react-router-dom";
import { LogOut } from "lucide-react";
import { roleLabels, scopeLabel } from "../lib/roles";
import { useAuthStore } from "../store/auth";
import { Card, Chip, InitialAvatar, PageHeader, PanelsMenu, btnDanger } from "../components/ui";
export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const clear = useAuthStore((s) => s.clear);
const logout = () => {
clear();
navigate("/login", { replace: true });
};
return (
<>
<PageHeader title="تنظیمات" menu={<PanelsMenu />} />
<div className="space-y-3 px-4 pb-28 pt-4">
{/* کارت کاربر */}
<Card className="p-4">
<div className="flex items-center gap-3">
<InitialAvatar name={user?.display_name ?? user?.username} className="h-12 w-12 text-base" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-slate-800">
{user?.display_name || user?.username || "کاربر"}
</p>
<p dir="ltr" className="mt-0.5 text-right text-[11px] text-slate-400">
{user?.username ?? "—"}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{user && <Chip tone="sky">{roleLabels[user.role]}</Chip>}
<Chip tone="slate">{scopeLabel(user)}</Chip>
</div>
</Card>
{/* خروج */}
<button type="button" onClick={logout} className={`${btnDanger} w-full`}>
<LogOut size={16} />
خروج از حساب
</button>
<p className="pt-4 text-center text-[11px] text-slate-400">PM v0.1</p>
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
+665
View File
@@ -0,0 +1,665 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronLeft, ClipboardList, Info, Pencil, Plus, Trash2 } from "lucide-react";
import {
addTaskQcQuestion,
createTaskQcDefinition,
deleteTaskQcDefinition,
deleteTaskQcQuestion,
getQcTemplates,
getTaskQc,
updateTaskQcDefinition,
updateTaskQcQuestion,
} from "../lib/api";
import type { QcPhase, QcQuestionPatch, QcTemplate, TaskQcDefinition, TaskQcQuestion } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
Combobox,
EmptyState,
ErrorState,
Modal,
PageHeader,
btnPrimary,
btnSecondary,
inputCls,
ConfirmSheet,
apiErrorToast,
} from "../components/ui";
import { phaseTone, qcPhases, qcSummaryStatusMeta } from "../lib/format";
const qcKey = (taskId: string | number) => ["task-qc", taskId];
// ----------------------------------------
// بات‌شیت انتخاب قالب سراسری
// ----------------------------------------
function TemplatePickerSheet({
templates,
pending,
onClose,
onSubmit,
}: {
templates: QcTemplate[];
pending: boolean;
onClose: () => void;
onSubmit: (templateId: string | undefined, name?: string) => void;
}) {
const [selected, setSelected] = useState<string>("");
const [customName, setCustomName] = useState("");
const template = templates.find((t) => t.id === selected) ?? null;
const copyCount = template?.questions.length ?? 0;
return (
<Modal open title="تعریف QC برای این وظیفه" onClose={onClose}>
<p className="text-[11px] leading-5 text-slate-500">
سؤالات قالب انتخابی به صورت کپی مستقل روی این وظیفه ساخته می‌شود؛ ویرایش آن‌ها قالب سراسری را تغییر نمی‌دهد.
</p>
<label className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
قالب سراسری
</label>
<Combobox
value={selected}
onChange={setSelected}
options={[
{ value: "", label: "بدون قالب (تعریف خالی)" },
...templates.map((t) => ({
value: t.id,
label: t.name,
hint: `${t.questions.length} سؤال`,
})),
]}
/>
<label htmlFor="qc-defname" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نام تعریف (اختیاری)
</label>
<input
id="qc-defname"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
className={inputCls}
placeholder={template?.name ?? "QC تسک"}
/>
{template && (
<div className="mt-3 rounded-xl border border-slate-200 p-3">
<p className="text-[11px] font-semibold text-slate-600">
{copyCount} سؤال کپی می‌شود:
</p>
<div className="mt-2 space-y-2">
{qcPhases.map((phase) => {
const questions = template.questions.filter((q) => q.phase === phase.value);
if (questions.length === 0) return null;
return (
<div key={phase.value}>
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{questions.map((q) => (
<li key={q.id} className="text-[11px] leading-4 text-slate-600">
{q.text}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={pending}
onClick={() => onSubmit(selected || undefined, customName.trim() || undefined)}
className={btnPrimary}
>
{pending ? "در حال ساخت..." : "ایجاد تعریف"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// بات‌شیت افزودن/ویرایش سؤال تسک
// ----------------------------------------
function QuestionSheet({
question,
pending,
onClose,
onSubmit,
}: {
/** null یعنی سؤال جدید */
question: TaskQcQuestion | null;
pending: boolean;
onClose: () => void;
onSubmit: (payload: { text: string; phase: QcPhase; required: boolean }) => void;
}) {
const [text, setText] = useState(() => question?.text ?? "");
const [phase, setPhase] = useState<QcPhase>(() => question?.phase ?? "pre");
const submit = () => {
if (!text.trim()) return;
onSubmit({ text: text.trim(), phase, required: true });
};
return (
<Modal open title={question ? "ویرایش سؤال" : "افزودن سؤال"} onClose={onClose}>
<label htmlFor="tq-text" className="mb-1 block text-[11px] font-medium text-slate-500">
متن سؤال *
</label>
<textarea
id="tq-text"
value={text}
onChange={(e) => setText(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
placeholder="مثلاً: مصالح مورد نیاز موجود است؟"
/>
<label htmlFor="tq-phase" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
مرحله
</label>
<select id="tq-phase" value={phase} onChange={(e) => setPhase(e.target.value as QcPhase)} className={inputCls}>
{qcPhases.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending || !text.trim()} onClick={submit} className={btnPrimary}>
{pending ? "در حال ذخیره..." : question ? "ذخیره" : "افزودن"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// یک تعریف QC (نام + سؤالات)
// ----------------------------------------
function DefinitionCard({
definition,
taskId,
onRename,
onDelete,
}: {
definition: TaskQcDefinition;
taskId: string;
onRename: (name: string) => void;
onDelete: () => void;
}) {
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(true);
const [addPhase, setAddPhase] = useState<QcPhase | null>(null);
const [editTarget, setEditTarget] = useState<TaskQcQuestion | null>(null);
const [deleteQuestion, setDeleteQuestion] = useState<TaskQcQuestion | null>(null);
const [renaming, setRenaming] = useState(false);
const [nameDraft, setNameDraft] = useState(definition.name);
const invalidate = () => void queryClient.invalidateQueries({ queryKey: qcKey(taskId) });
const addMutation = useMutation({
mutationFn: (payload: { text: string; phase: QcPhase; required: boolean }) =>
addTaskQcQuestion(definition.id, payload),
onSuccess: () => {
setAddPhase(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "افزودن سؤال ناموفق بود"),
});
const editMutation = useMutation({
mutationFn: (args: { questionId: string; patch: QcQuestionPatch }) =>
updateTaskQcQuestion(args.questionId, args.patch),
onSuccess: () => {
setEditTarget(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "ویرایش سؤال ناموفق بود"),
});
const reorderMutation = useMutation({
mutationFn: async (swap: { a: TaskQcQuestion; b: TaskQcQuestion }) => {
await updateTaskQcQuestion(swap.a.id, { sort_order: swap.b.sort_order });
await updateTaskQcQuestion(swap.b.id, { sort_order: swap.a.sort_order });
},
onSuccess: invalidate,
onError: (err) => apiErrorToast(err, "جابه‌جایی ناموفق بود"),
});
const deleteQuestionMutation = useMutation({
mutationFn: (questionId: string) => deleteTaskQcQuestion(questionId),
onSuccess: () => {
setDeleteQuestion(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "حذف سؤال ناموفق بود"),
});
const move = (question: TaskQcQuestion, direction: -1 | 1) => {
const inPhase = definition.questions
.filter((q) => q.phase === question.phase)
.sort((a, b) => a.sort_order - b.sort_order);
const pos = inPhase.findIndex((q) => q.id === question.id);
const other = inPhase[pos + direction];
if (!other) return;
reorderMutation.mutate({ a: question, b: other });
};
return (
<Card className="p-0">
<div className="flex items-start justify-between gap-2 p-4">
{renaming ? (
<div className="min-w-0 flex-1">
<input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} className={`${inputCls} h-10`} />
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => {
const next = nameDraft.trim();
if (!next) return;
onRename(next);
setRenaming(false);
}}
className={`${btnPrimary} h-9 flex-1 text-xs`}
>
ذخیره نام
</button>
<button
type="button"
onClick={() => {
setNameDraft(definition.name);
setRenaming(false);
}}
className={`${btnSecondary} h-9 flex-1 text-xs`}
>
انصراف
</button>
</div>
</div>
) : (
<>
<button type="button" onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-right">
<span className="block truncate text-[13px] font-bold text-slate-800">{definition.name}</span>
<span className="mt-1.5 flex flex-wrap gap-1.5">
<Chip tone="sky">{definition.questions.length} سؤال</Chip>
{definition.source_template_id && <Chip tone="slate">از قالب سراسری</Chip>}
</span>
</button>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => {
setNameDraft(definition.name);
setRenaming(true);
}}
aria-label="تغییر نام"
title="تغییر نام"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 active:bg-slate-100"
>
<Pencil size={15} />
</button>
<button
type="button"
onClick={onDelete}
aria-label="حذف تعریف"
title="حذف تعریف"
className="flex h-9 w-9 items-center justify-center rounded-lg text-red-500 active:bg-red-50"
>
<Trash2 size={15} />
</button>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label="باز و بسته کردن"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-400 active:bg-slate-100"
>
{expanded ? <ChevronDown size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</>
)}
</div>
{expanded && (
<div className="border-t border-slate-100 px-4 py-3">
{definition.questions.length === 0 && (
<p className="py-1 text-[11px] text-slate-400">
این تعریف سؤالی ندارد؛ با «افزودن سؤال» شروع کنید.
</p>
)}
<div className="space-y-3">
{qcPhases.map((phase) => {
const questions = definition.questions
.filter((q) => q.phase === phase.value)
.sort((a, b) => a.sort_order - b.sort_order);
return (
<div key={phase.value}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<button
type="button"
onClick={() => setAddPhase(phase.value as QcPhase)}
className="inline-flex h-7 items-center gap-1 rounded-full bg-sky-50 px-2 text-[11px] font-semibold text-sky-700 active:bg-sky-100"
>
<Plus size={12} />
افزودن
</button>
</div>
{questions.length === 0 ? (
<p className="py-1 text-[11px] text-slate-400">سؤالی در این مرحله نیست</p>
) : (
<div className="space-y-1.5">
{questions.map((q, indexInPhase) => (
<div key={q.id} className="flex items-start justify-between gap-2 rounded-xl border border-slate-200 p-2.5">
<div className="min-w-0 flex-1">
<p className="text-xs leading-5 text-slate-700">{q.text}</p>
{q.result && (
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Chip tone="sky">پاسخ ثبت شده</Chip>
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
onClick={() => move(q, -1)}
disabled={indexInPhase === 0 || reorderMutation.isPending}
aria-label="انتقال به بالا"
title="انتقال به بالا"
className="flex h-6 w-7 items-center justify-center rounded-md text-slate-400 active:bg-slate-100 disabled:opacity-30"
>
▲
</button>
<button
type="button"
onClick={() => move(q, 1)}
disabled={indexInPhase === questions.length - 1 || reorderMutation.isPending}
aria-label="انتقال به پایین"
title="انتقال به پایین"
className="flex h-6 w-7 items-center justify-center rounded-md text-slate-400 active:bg-slate-100 disabled:opacity-30"
>
▼
</button>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
onClick={() => setEditTarget(q)}
aria-label="ویرایش سؤال"
title="ویرایش سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 active:bg-slate-100"
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => setDeleteQuestion(q)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-red-500 active:bg-red-50"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{addPhase && (
<QuestionSheet
question={null}
pending={addMutation.isPending}
onClose={() => setAddPhase(null)}
onSubmit={(payload) => addMutation.mutate(payload)}
/>
)}
{editTarget && (
<QuestionSheet
question={editTarget}
pending={editMutation.isPending}
onClose={() => setEditTarget(null)}
onSubmit={(payload) =>
editMutation.mutate({
questionId: editTarget.id,
patch: { text: payload.text, phase: payload.phase, required: payload.required },
})
}
/>
)}
{deleteQuestion && (
<ConfirmSheet
open
title="حذف سؤال"
message={`آیا «${deleteQuestion.text}» از تعریف این وظیفه حذف شود؟`}
pending={deleteQuestionMutation.isPending}
onClose={() => setDeleteQuestion(null)}
onConfirm={() => deleteQuestionMutation.mutate(deleteQuestion.id)}
/>
)}
</Card>
);
}
// ----------------------------------------
// صفحه تعریف QC
// ----------------------------------------
export default function TaskQcDefinePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
// شناسهٔ تسک میتواند UUID بومی باشد یا شناسهٔ عددی میراث — هر دو رشتهاند
const taskId = (id ?? "").trim();
const validId = taskId.length > 0;
const qcQuery = useQuery({ queryKey: qcKey(taskId), queryFn: () => getTaskQc(taskId), enabled: validId });
const templatesQuery = useQuery({ queryKey: ["qc-templates"], queryFn: getQcTemplates });
const [showPicker, setShowPicker] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<TaskQcDefinition | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qcKey(taskId) });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const createMutation = useMutation({
mutationFn: (args: { templateId?: string; name?: string }) =>
createTaskQcDefinition(taskId, { template_id: args.templateId, name: args.name }),
onSuccess: () => {
setShowPicker(false);
invalidate();
},
onError: (err) => apiErrorToast(err, "ساخت تعریف ناموفق بود"),
});
const renameMutation = useMutation({
mutationFn: (args: { definitionId: string; name: string }) =>
updateTaskQcDefinition(args.definitionId, args.name),
onSuccess: invalidate,
onError: (err) => apiErrorToast(err, "تغییر نام ناموفق بود"),
});
const deleteMutation = useMutation({
mutationFn: (definitionId: string) => deleteTaskQcDefinition(definitionId),
onSuccess: () => {
setDeleteTarget(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "حذف تعریف ناموفق بود"),
});
if (!validId) {
return (
<>
<PageHeader title="تعریف QC" onBack={() => navigate("/")} />
<Card className="m-4">
<ErrorState message="شناسه وظیفه نامعتبر است" onRetry={() => navigate("/")} />
</Card>
</>
);
}
if (qcQuery.isPending) {
return (
<>
<PageHeader title="تعریف QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<CardSkeleton />
<CardSkeleton />
</div>
</>
);
}
if (qcQuery.isError || !qcQuery.data) {
return (
<>
<PageHeader title="تعریف QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<Card className="m-4">
<ErrorState message="خطا در دریافت تعریف‌های QC" onRetry={() => void qcQuery.refetch()} />
</Card>
</>
);
}
const data = qcQuery.data;
const templates = templatesQuery.data ?? [];
return (
<>
<PageHeader
title="تعریف QC"
onBack={() => navigate(`/tasks/${taskId}`)}
right={
<button
type="button"
onClick={() => setShowPicker(true)}
className="inline-flex h-9 items-center gap-1 rounded-full bg-sky-50 px-3 text-xs font-semibold text-sky-700 active:bg-sky-100"
>
<Plus size={14} />
تعریف از قالب
</button>
}
/>
<div className="space-y-3 px-4 pb-28 pt-4">
<Card className="p-4">
<p className="text-[11px] text-slate-400">تعریف QC برای وظیفه</p>
<p className="mt-0.5 text-[13px] font-bold text-slate-800">{data.task.subject}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip tone="sky">{data.definitions.length} تعریف</Chip>
<Chip tone="slate">{data.readiness.all.total} سؤال</Chip>
<Chip tone={qcSummaryStatusMeta(data.readiness.pre.status).tone}>
پیش از اجرا: {qcSummaryStatusMeta(data.readiness.pre.status).label}
</Chip>
</div>
</Card>
<div className="flex items-start gap-2 rounded-xl bg-sky-50 p-3">
<Info size={15} className="mt-0.5 shrink-0 text-sky-600" />
<p className="text-[11px] leading-5 text-sky-800">
تغییرات این صفحه فقط روی همین وظیفه اثر دارد و هرگز قالب سراسری را تغییر نمی‌دهد. ارزیابی (قبول/مردود) در
صفحه «اجرای QC» ثبت می‌شود.
</p>
</div>
{data.definitions.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardList}
title="تعریفی ساخته نشده است"
description="با «تعریف از قالب» یک تعریف بسازید یا تعریف خالی ایجاد کنید"
/>
<div className="px-4 pb-4">
<button
type="button"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate({})}
className={`${btnSecondary} w-full text-xs`}
>
{createMutation.isPending ? "در حال ساخت..." : "ایجاد تعریف خالی"}
</button>
</div>
</Card>
) : (
data.definitions.map((definition) => (
<DefinitionCard
key={definition.id}
definition={definition}
taskId={taskId}
onRename={(name) => renameMutation.mutate({ definitionId: definition.id, name })}
onDelete={() => setDeleteTarget(definition)}
/>
))
)}
{data.definitions.length > 0 && (
<div className="grid grid-cols-2 gap-2 pt-1">
<Link to={`/tasks/${taskId}/qc/execute`} className={btnPrimary}>
اجرای QC
</Link>
<button
type="button"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate({})}
className={btnSecondary}
>
تعریف خالی
</button>
</div>
)}
</div>
{showPicker && (
<TemplatePickerSheet
templates={templates}
pending={createMutation.isPending}
onClose={() => setShowPicker(false)}
onSubmit={(templateId, name) => createMutation.mutate({ templateId, name })}
/>
)}
{deleteTarget && (
<ConfirmSheet
open
title="حذف تعریف QC"
message={`آیا «${deleteTarget.name}» با ${deleteTarget.questions.length} سؤال از این وظیفه حذف شود؟`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
+586
View File
@@ -0,0 +1,586 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, Camera, CheckCircle2, ClipboardList, ImagePlus, ShieldCheck } from "lucide-react";
import { ApiError, createDefect, getTask, getTaskQc, putTaskQcResult, uploadFile } from "../lib/api";
import type { AnswerValue, QcState, TaskQcQuestion, TaskQcState } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
ProgressBar,
btnPrimary,
btnSecondary,
inputCls,
apiErrorToast,
} from "../components/ui";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
import { answerMeta, answerOptions, phaseLabel, phaseTone, qcPhases, qcSummaryStatusMeta, severityOptions } from "../lib/format";
const qcKey = (taskId: string | number) => ["task-qc", taskId];
/** خلاصه بالا: پیشرفت QC + وضعیت پیش از اجرا + وضعیت اجرا */
function SummaryCard({ readiness, taskSubject }: { readiness: QcState; taskSubject: string }) {
const answered = readiness.all.total - readiness.all.pending;
const percent = readiness.all.total > 0 ? Math.round((answered / readiness.all.total) * 100) : 0;
const pre = qcSummaryStatusMeta(readiness.pre.status);
const exec = qcSummaryStatusMeta(readiness.exec.status);
return (
<Card className="p-4">
<p className="text-[11px] text-slate-400">اجرای QC برای وظیفه</p>
<p className="mt-0.5 text-[13px] font-bold text-slate-800">{taskSubject}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-[11px] font-semibold text-slate-500">پیشرفت QC</span>
<span className="text-xs font-bold text-sky-700">
{answered} از {readiness.all.total} سؤال
</span>
</div>
<ProgressBar value={percent} className="mt-2" />
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-xl border border-slate-200 p-2.5">
<p className="text-[10px] font-medium text-slate-400">وضعیت پیش از اجرا</p>
<div className="mt-1.5">
<Chip tone={pre.tone}>{pre.label}</Chip>
</div>
<p className="mt-1 text-[10px] text-slate-400">
قبول {readiness.pre.passed} / مردود {readiness.pre.failed} / بدون پاسخ {readiness.pre.pending}
</p>
</div>
<div className="rounded-xl border border-slate-200 p-2.5">
<p className="text-[10px] font-medium text-slate-400">وضعیت اجرا</p>
<div className="mt-1.5">
<Chip tone={exec.tone}>{exec.label}</Chip>
</div>
<p className="mt-1 text-[10px] text-slate-400">
قبول {readiness.exec.passed} / مردود {readiness.exec.failed} / بدون پاسخ {readiness.exec.pending}
</p>
</div>
</div>
{!readiness.can_start && readiness.block_reasons.length > 0 && (
<div className="mt-3 rounded-xl bg-amber-50 p-2.5">
<p className="flex items-center gap-1.5 text-[11px] font-bold text-amber-800">
<AlertTriangle size={14} />
بازرسی پیش از اجرا تأیید نشده است
</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{readiness.block_reasons.map((reason) => (
<li key={reason} className="text-[11px] leading-4 text-amber-700">
{reason}
</li>
))}
</ul>
</div>
)}
{readiness.all.total > 0 && readiness.all.pending === 0 && readiness.all.failed === 0 && (
<div className="mt-3 flex items-center gap-2 rounded-xl bg-emerald-50 p-2.5">
<CheckCircle2 size={16} className="shrink-0 text-emerald-600" />
<p className="text-[11px] font-bold text-emerald-700">
همه بازرسی‌ها انجام شد — پیشرفت ۱۰۰٪ مجاز است
</p>
</div>
)}
</Card>
);
}
// ----------------------------------------
// بات‌شیت ثبت عیب
// ----------------------------------------
function DefectSheet({
question,
taskId,
onClose,
onDone,
}: {
question: TaskQcQuestion;
taskId: string;
onClose: () => void;
onDone: () => void;
}) {
const [title, setTitle] = useState(() => question.text);
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState("medium");
const [correctiveAction, setCorrectiveAction] = useState("");
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const saveMutation = useMutation({
mutationFn: async () => {
let photoId: string | undefined;
if (photoFile) {
photoId = (await uploadFile(photoFile)).id;
}
return createDefect({
task_ref: taskId,
question_id: question.id,
title: title.trim(),
description: description.trim() || undefined,
severity: severity as "low" | "medium" | "high",
corrective_action: correctiveAction.trim() || undefined,
photo_id: photoId,
});
},
onSuccess: () => {
toast.success("عیب ثبت شد");
onDone();
},
onError: (err) => apiErrorToast(err, "ثبت عیب ناموفق بود"),
});
const submit = () => {
setFormError(null);
if (!title.trim()) {
setFormError("عنوان عیب الزامی است");
return;
}
saveMutation.mutate();
};
return (
<Modal open title="ثبت عیب" onClose={onClose}>
<p className="rounded-lg bg-red-50 p-2 text-[11px] leading-4 text-red-600">
سؤال مردود: {question.text}
</p>
<label htmlFor="d-title" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
عنوان عیب *
</label>
<input id="d-title" value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} />
<label htmlFor="d-desc" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
توضیحات
</label>
<textarea
id="d-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
/>
<label htmlFor="d-severity" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
شدت عیب
</label>
<select id="d-severity" value={severity} onChange={(e) => setSeverity(e.target.value)} className={inputCls}>
{severityOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<label htmlFor="d-action" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
اقدام اصلاحی
</label>
<textarea
id="d-action"
value={correctiveAction}
onChange={(e) => setCorrectiveAction(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
/>
<p className="mb-1 mt-3 text-[11px] font-medium text-slate-500">عکس عیب (اختیاری)</p>
<div className="flex items-center gap-2">
<label className={`${btnSecondary} h-10 cursor-pointer px-3 text-xs`}>
<ImagePlus size={14} />
انتخاب عکس
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => setPhotoFile(e.target.files?.[0] ?? null)}
/>
</label>
{photoFile && <span className="min-w-0 flex-1 truncate text-[11px] text-slate-500">{photoFile.name}</span>}
</div>
{formError && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{formError}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={saveMutation.isPending} onClick={submit} className={btnPrimary}>
{saveMutation.isPending ? "در حال ثبت..." : "ثبت عیب"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// ردیف سؤال: انتخاب سه‌گانه + توضیح
// ----------------------------------------
function QuestionRow({
question,
hasOpenDefect,
onAnswer,
onComment,
onDefect,
saving,
}: {
question: TaskQcQuestion;
hasOpenDefect: boolean;
onAnswer: (answer: AnswerValue | null, comment: string, photoId?: string | null) => void;
onComment: (answer: AnswerValue | null, comment: string) => void;
onDefect: () => void;
saving: boolean;
}) {
const savedAnswer = question.result?.answer ?? null;
const [comment, setComment] = useState(() => question.result?.comment ?? "");
const [warned, setWarned] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const current = answerMeta(savedAnswer);
const commentDirty = comment.trim() !== (question.result?.comment ?? "").trim();
return (
<div className="py-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{question.text}</p>
<span className="flex shrink-0 flex-col items-end gap-1">
<Chip tone={current.tone}>{current.label}</Chip>
</span>
</div>
{(hasOpenDefect || question.history_count > 1) && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{hasOpenDefect && <Chip tone="amber">عیب باز</Chip>}
{question.history_count > 1 && <Chip tone="slate">{question.history_count} ارزیابی ثبت شده</Chip>}
</div>
)}
<div className="mt-2 grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{answerOptions.map((o) => {
const selected = savedAnswer === o.value;
return (
<button
key={o.value}
type="button"
disabled={saving || uploadingPhoto}
onClick={() => {
setWarned(false);
if (selected) {
onAnswer(null, comment);
} else {
onAnswer(o.value as AnswerValue, comment);
}
}}
className={`h-9 rounded-lg text-[11px] font-semibold transition-colors disabled:opacity-60 ${
selected ? o.activeCls : "text-slate-500"
}`}
>
{o.label}
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-2">
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="توضیح (اختیاری)"
className={`${inputCls} h-10 min-w-0 flex-1 text-xs`}
/>
<button
type="button"
disabled={!commentDirty || !savedAnswer || saving || !comment.trim()}
onClick={() => {
if (!savedAnswer) {
setWarned(true);
return;
}
onComment(savedAnswer, comment);
}}
className={`${btnSecondary} h-10 shrink-0 px-3 text-xs`}
>
ثبت توضیح
</button>
</div>
{warned && <p className="mt-1 text-[10px] text-red-500">ابتدا یکی از حالت‌های قبول/مردود/مطرح نیست را بزنید.</p>}
{/* بخش آپلود مستند تصویری در حالت قبولی (مشابه بخش ثبت عیب) */}
{savedAnswer === "pass" && (
<div className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50/60 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-xs font-semibold text-emerald-800">
<Camera size={14} className="text-emerald-600" />
<span>تصویر مستند تأییدیه (قبول)</span>
</span>
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-emerald-300 bg-white px-2.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50 transition-colors shadow-xs">
<ImagePlus size={13} />
<span>{uploadingPhoto ? "در حال آپلود..." : question.result?.photo_url ? "تغییر تصویر" : "انتخاب و آپلود تصویر..."}</span>
<input
type="file"
accept="image/*"
className="hidden"
disabled={uploadingPhoto || saving}
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setUploadingPhoto(true);
try {
const up = await uploadFile(f);
onAnswer(savedAnswer, comment, up.id);
toast.success("تصویر تأییدیه با موفقیت ضمیمه شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
} finally {
setUploadingPhoto(false);
}
}}
/>
</label>
</div>
{question.result?.photo_url && (
<div className="mt-2 flex items-center gap-2.5 pt-2 border-t border-emerald-200/70">
<a href={question.result.photo_url} target="_blank" rel="noreferrer" className="block shrink-0">
<img
src={question.result.photo_url}
alt="مستند قبولی"
className="h-14 w-14 rounded-lg object-cover border border-emerald-300 shadow-xs hover:opacity-90 transition-opacity"
/>
</a>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-emerald-900 truncate">تصویر پیوست شده</p>
<a
href={question.result.photo_url}
target="_blank"
rel="noreferrer"
className="text-[10px] text-emerald-600 hover:underline"
>
مشاهده در اندازه کامل
</a>
</div>
<button
type="button"
disabled={saving || uploadingPhoto}
onClick={() => onAnswer(savedAnswer, comment, "")}
className="text-xs text-red-500 hover:text-red-700 hover:underline px-1 py-0.5"
title="حذف تصویر پیوست"
>
حذف تصویر
</button>
</div>
)}
</div>
)}
{savedAnswer === "fail" && !hasOpenDefect && (
<button
type="button"
onClick={onDefect}
className={`${btnSecondary} mt-2 h-9 w-full text-xs text-red-600`}
>
<AlertTriangle size={13} />
ثبت عیب
</button>
)}
{question.result?.evaluated_at && (
<p className="mt-1.5 text-[10px] text-slate-400">
آخرین ارزیابی: {question.result.evaluated_by || "—"}
</p>
)}
</div>
);
}
// ----------------------------------------
// صفحه اجرای QC
// ----------------------------------------
export default function TaskQcExecutePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const canDefineQc = hasPermission(user, Perm.qcDefine);
// شناسهٔ تسک میتواند UUID بومی باشد یا شناسهٔ عددی میراث — هر دو رشتهاند
const taskId = (id ?? "").trim();
const validId = taskId.length > 0;
const qcQuery = useQuery({ queryKey: qcKey(taskId), queryFn: () => getTaskQc(taskId), enabled: validId });
/** عیب‌های باز وظیفه — برای نشان دادن «عیب باز» روی سؤال‌های مردود */
const taskQuery = useQuery({ queryKey: ["task", taskId], queryFn: () => getTask(taskId), enabled: validId });
const [defectQuestion, setDefectQuestion] = useState<TaskQcQuestion | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qcKey(taskId) });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
/** readiness برگشتی از PUT — تا رسیدن داده تازه سرور نمایش داده می‌شود */
const [live, setLive] = useState<QcState | null>(null);
const dataUpdatedAt = qcQuery.dataUpdatedAt;
useEffect(() => {
setLive(null);
}, [dataUpdatedAt]);
const answerMutation = useMutation({
mutationFn: (args: { questionId: string; answer: AnswerValue | null; comment: string; photoId?: string | null }) =>
putTaskQcResult(args.questionId, { answer: args.answer, comment: args.comment, photo_id: args.photoId }),
onSuccess: (data, variables) => {
setLive(data.readiness);
invalidate();
if (variables.answer === null) {
toast.success("انتخاب بازرسی لغو شد");
}
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت پاسخ ناموفق بود"),
});
if (!validId) {
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate("/")} />
<Card className="m-4">
<ErrorState message="شناسه وظیفه نامعتبر است" onRetry={() => navigate("/")} />
</Card>
</>
);
}
if (qcQuery.isPending) {
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<CardSkeleton />
<CardSkeleton />
</div>
</>
);
}
if (qcQuery.isError || !qcQuery.data) {
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<Card className="m-4">
<ErrorState message="خطا در دریافت بازرسی‌های وظیفه" onRetry={() => void qcQuery.refetch()} />
</Card>
</>
);
}
const data: TaskQcState = qcQuery.data;
const state = live ?? data.readiness;
const openDefectQuestionIds = new Set(
(taskQuery.data?.defects ?? [])
.filter((d) => d.status !== "resolved" && d.question_id)
.map((d) => d.question_id as string),
);
const questions = data.definitions.flatMap((definition) =>
definition.questions.map((q) => ({ question: q, definitionName: definition.name })),
);
const saving = answerMutation.isPending;
const save = (question: TaskQcQuestion, answer: AnswerValue | null, comment: string, photoId?: string | null) => {
answerMutation.mutate({ questionId: question.id, answer, comment, photoId });
};
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<SummaryCard readiness={state} taskSubject={data.task.subject} />
{questions.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardList}
title="سؤالی برای بازرسی تعریف نشده است"
description={
canDefineQc
? "ابتدا در صفحه «تعریف QC» سؤالات این وظیفه را بسازید"
: "ساختار بازرسی این وظیفه توسط مدیر پروژه تعریف نشده است"
}
/>
{canDefineQc && (
<div className="px-4 pb-4">
<Link to={`/tasks/${taskId}/qc/define`} className={`${btnPrimary} w-full`}>
رفتن به تعریف QC
</Link>
</div>
)}
</Card>
) : (
<>
{qcPhases.map((phase) => {
const rows = questions.filter((item) => item.question.phase === phase.value);
if (rows.length === 0) return null;
const summary = phase.value === "pre" ? state.pre : null;
return (
<Card key={phase.value} className="p-0">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-3">
<span className="flex items-center gap-1.5">
<Chip tone={phaseTone(phase.value)}>{phaseLabel(phase.value)}</Chip>
{phase.value === "pre" && <Chip tone="slate">شرط شروع اجرا</Chip>}
</span>
{summary && (
<span className="text-[10px] text-slate-400">
قبول {summary.passed} / مردود {summary.failed} / بدون پاسخ {summary.pending}
</span>
)}
</div>
<div className="divide-y divide-slate-100 px-4">
{rows.map(({ question }) => (
<QuestionRow
key={question.id}
question={question}
saving={saving}
hasOpenDefect={openDefectQuestionIds.has(question.id)}
onAnswer={(answer, comment, photoId) => save(question, answer, comment, photoId)}
onComment={(answer, comment) => save(question, answer, comment)}
onDefect={() => setDefectQuestion(question)}
/>
))}
</div>
</Card>
);
})}
<div className="flex items-center gap-2 rounded-xl bg-slate-100 p-3">
<ShieldCheck size={16} className="shrink-0 text-slate-500" />
<p className="text-[11px] leading-5 text-slate-600">
پاسخ‌ها به صورت تاریخچه ثبت می‌شوند؛ در صورت اصلاح عیب می‌توانید همان سؤال را دوباره بازرسی کنید.
</p>
</div>
</>
)}
</div>
{defectQuestion && (
<DefectSheet
question={defectQuestion}
taskId={taskId}
onClose={() => setDefectQuestion(null)}
onDone={() => {
setDefectQuestion(null);
invalidate();
}}
/>
)}
</>
);
}
+321
View File
@@ -0,0 +1,321 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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, ProgressBar } from "../components/ui";
import { formatDate, isOverdue } from "../lib/format";
import { roleLabels, scopeLabel } from "../lib/roles";
import { useAuthStore } from "../store/auth";
type SectionKey = "overdue" | "due_today" | "awaiting_qc" | "failed_qc" | "blocked" | "upcoming";
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 (
<Link to={`/tasks/${taskKey}`} className="block py-3">
<div className="flex items-start justify-between gap-2">
<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="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 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>
</Link>
);
}
function SummaryBox({ tone, label, value }: { tone: "red" | "amber" | "green"; label: string; value: number }) {
const cls = {
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];
return (
<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>
);
}
/** کاشی مدیریتی یک پروژه — فقط در داشبورد مدیر پروژه */
function ProjectTile({ project }: { project: Project }) {
return (
<Link to={`/projects/${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);
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDevMode = !import.meta.env.PROD;
const rolesQuery = useQuery({
queryKey: ["dev-roles"],
queryFn: getDevRoles,
staleTime: 60_000,
retry: false,
enabled: isDevMode,
});
const switchMutation = useMutation({
mutationFn: (username: string) => devLogin(username),
onSuccess: (data) => {
queryClient.clear();
setAuth(data.token, data.user);
navigate("/", { replace: true });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["dev-roles"] });
},
});
if (!isDevMode || rolesQuery.isError || !rolesQuery.data || rolesQuery.data.length === 0) {
return null;
}
const roles = rolesQuery.data;
return (
<select
aria-label="تغییر نقش (فاز توسعه)"
value={user?.username ?? ""}
disabled={switchMutation.isPending}
onChange={(e) => switchMutation.mutate(e.target.value)}
className="h-9 max-w-[9.5rem] shrink-0 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-2 text-[11px] font-medium text-amber-800 focus:border-amber-500 focus:outline-none disabled:opacity-60"
>
{roles.map((r) => (
<option key={r.username} value={r.username}>
{roleLabels[r.role]} — {r.all_projects ? "همه پروژه‌ها" : `${r.project_count} پروژه`}
</option>
))}
</select>
);
}
export default function TodayPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const [showUpcoming, setShowUpcoming] = useState(false);
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
const data = todayQuery.data;
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["today"] });
};
const isManager = user?.role === "project_manager";
const role = data?.role ?? user?.role ?? "project_supervisor";
const sections = isManager ? managerSections : supervisorSections;
const projects = data?.projects ?? [];
const totalItems = data
? Object.values(data.sections).reduce((sum, list) => sum + list.length, 0)
: 0;
return (
<>
<PageHeader
title="مدیریت پروژه"
menu={<PanelsMenu />}
right={
<span className="flex items-center gap-1">
<DevRoleSwitcher />
<button
type="button"
onClick={refresh}
aria-label="به‌روزرسانی"
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
>
<RefreshCw size={20} className={todayQuery.isFetching ? "animate-spin" : ""} />
</button>
</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>
{todayQuery.isPending ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<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 ? (
<ErrorState onRetry={refresh} />
) : data ? (
<>
<div className="grid grid-cols-3 gap-2">
<SummaryBox
tone="red"
label={isManager ? "مشکلات" : "بحرانی"}
value={data.summary.problems}
/>
<SummaryBox
tone="amber"
label={isManager ? "هشدارها" : "نیازمند پیگیری"}
value={data.summary.warnings}
/>
<SummaryBox tone="green" label="سالم" value={data.summary.ok} />
</div>
{/* نمای مدیریتی پروژه‌ها — فقط مدیر پروژه */}
{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">
{projects.map((p) => (
<ProjectTile key={p.op_project_id} project={p} />
))}
</div>
</Card>
)}
{/* ناظر بدون پروژه اختصاص‌یافته */}
{!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>
)}
</>
) : null}
</div>
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { create } from "zustand";
import type { User } from "../lib/api";
const TOKEN_KEY = "pm_token";
const USER_KEY = "pm_user";
/** نشست ERP روی همین دامنه (zustand persist) — منبع تک‌احراز هویت مجموعه */
const ERP_SESSION_KEY = "procurement-auth-v1";
function readErpToken(): string | null {
try {
const raw = localStorage.getItem(ERP_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { token?: string | null } };
return parsed.state?.token ?? null;
} catch {
return null;
}
}
function readToken(): string | null {
try {
const legacy = localStorage.getItem(TOKEN_KEY);
if (legacy) return legacy;
// SSO — توکن Casdoorِ نشست ERP همان نشست PM است
return readErpToken();
} catch {
return null;
}
}
interface AuthState {
token: string | null;
user: User | null;
setAuth: (token: string, user: User) => void;
/** به‌روزرسانی کاربر جاری (بعد از تغییر نقش/دسترسی‌ها یا خواندن /auth/me) */
setUser: (user: User) => void;
clear: () => void;
}
function readErpUser(): User | null {
try {
const raw = localStorage.getItem(ERP_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as {
state?: {
user?: {
first_name?: string;
last_name?: string;
display_name?: string;
username?: string;
casdoor_id?: string;
role?: string;
role_label?: string;
} | null;
};
};
const eu = parsed.state?.user;
if (!eu) return null;
const role: import("../lib/api").Role =
eu.role === "supervisor" ? "project_supervisor" : "project_manager";
return {
id: eu.username || eu.casdoor_id || "sso",
username: eu.username || eu.casdoor_id || "user",
display_name: eu.display_name || `${eu.first_name || ""} ${eu.last_name || ""}`.trim() || eu.username || "کاربر",
role,
role_label: eu.role_label || (role === "project_supervisor" ? "سرپرست پروژه" : "مدیر پروژه"),
permissions: [],
scope: { all_projects: role === "project_manager", project_count: null },
};
} catch {
return null;
}
}
function readUser(): User | null {
try {
const erpUser = readErpUser();
const raw = localStorage.getItem(USER_KEY);
if (raw) {
const pmUser = JSON.parse(raw) as User;
if (erpUser && (pmUser.username !== erpUser.username || pmUser.role !== erpUser.role)) {
localStorage.removeItem(USER_KEY);
return erpUser;
}
return pmUser;
}
return erpUser;
} catch {
return null;
}
}
function writeUser(user: User | null): void {
try {
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
else localStorage.removeItem(USER_KEY);
} catch {
/* حافظه در دسترس نیست */
}
}
/** وضعیت احراز هویت — توکن در localStorage نگه‌داری می‌شود */
export const useAuthStore = create<AuthState>()((set) => ({
token: readToken(),
user: readUser(),
setAuth: (token, user) => {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* حافظه در دسترس نیست */
}
writeUser(user);
set({ token, user });
},
setUser: (user) => {
writeUser(user);
set({ user });
},
clear: () => {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* حافظه در دسترس نیست */
}
writeUser(null);
set({ token: null, user: null });
},
}));
/** آیا نشست ذخیره‌شده اطلاعات نقش را دارد؟ (نشست‌های پیش از RBAC ندارند) */
export function sessionNeedsRefresh(user: User | null): boolean {
return !user || !user.role || !Array.isArray(user.permissions);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
// آدرس سامانه ERP (تدارکات/انبار) برای منوی «پنل‌ها» — خالی = پیش‌فرض لوکال
readonly VITE_ERP_URL?: string;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// PM زیر مسیر /pm/ روی همان دامنه ERP سرو می‌شود (پنل «مدیریت پروژه» داخل ERP)
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "/pm/",
server: {
port: 5173,
// در حالت dev، درخواست‌های /pm/api به بک‌اند PM (پورت 8083) پراکسی می‌شوند
proxy: {
"/pm/api": {
target: "http://localhost:8083",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/pm/, ""),
},
},
},
});
+239
View File
@@ -0,0 +1,239 @@
// ============================================================================
// Database Markup Language (DBML) Schema for Construction Project Management
// Website: https://dbdiagram.io / https://dbdocs.io
// ============================================================================
Project construction_pm {
database_type: 'PostgreSQL'
Note: 'پایگاه‌داده بومی سیستم مدیریت پروژه عمرانی و کنترل کیفیت کارگاهی'
}
// ------------------------------------------------------
// 1. Projects & Members
// ------------------------------------------------------
Table projects {
id uuid [pk, default: `gen_random_uuid()`, note: 'شناسه یکتای پروژه']
name varchar(255) [not null, note: 'نام پروژه']
description text [note: 'توضیحات و مشخصات فنی']
location varchar(255) [note: 'موقعیت جغرافیایی / کارگاه']
project_manager varchar(255) [note: 'نام مدیر پروژه / سرپرست']
status varchar(32) [not null, default: 'ACTIVE', note: 'PLANNED, ACTIVE, ON_HOLD, COMPLETED, CANCELLED']
planned_start date [note: 'تاریخ شروع برنامه‌ریزی‌شده (تجمیع خودکار از تسک‌ها)']
planned_end date [note: 'تاریخ پایان برنامه‌ریزی‌شده (تجمیع خودکار از تسک‌ها)']
actual_start date [note: 'تاریخ شروع واقعی (ثبت خودکار با آغاز کار)']
actual_end date [note: 'تاریخ پایان واقعی (ثبت خودکار در اتمام ۱۰۰٪)']
progress float [not null, default: 0.0, note: 'درصد پیشرفت وزنی کل پروژه (۰ تا ۱۰۰)']
created_at timestamptz [not null, default: `now()`]
updated_at timestamptz [not null, default: `now()`]
indexes {
name
status
}
}
Table project_members {
id uuid [pk, default: `gen_random_uuid()`]
project_id uuid [not null, note: 'پروژه مربوطه']
user_id uuid [not null, note: 'کاربر منتسب']
role varchar(32) [not null, default: 'site_engineer', note: 'project_manager, site_engineer, qc_inspector, member']
created_at timestamptz [not null, default: `now()`]
indexes {
(project_id, user_id) [unique]
}
}
Table project_assignments {
id uuid [pk, default: `gen_random_uuid()` display: 'دامنه دسترسی ناظران محدود']
user_id uuid [not null]
project_id uuid [not null]
created_at timestamptz [not null, default: `now()`]
}
// ------------------------------------------------------
// 2. Tasks & WBS Hierarchy
// ------------------------------------------------------
Table tasks {
id uuid [pk, default: `gen_random_uuid()`, note: 'شناسه یکتای وظیفه']
project_id uuid [not null, note: 'پروژه مربوطه']
parent_id uuid [null, note: 'والد در ساختار درختی WBS با عمق دلخواه']
title varchar(512) [not null, note: 'عنوان وظیفه']
description text [note: 'مشخصات، دستورکار و الزامات فنی']
status varchar(32) [not null, default: 'TODO', note: 'TODO, IN_PROGRESS, BLOCKED, COMPLETED, CANCELLED']
priority varchar(32) [not null, default: 'medium', note: 'low, medium, high, urgent']
assignee_id uuid [null, note: 'کاربر مسئول اجرا']
assignee_name varchar(255) [null, note: 'نام نمایشی مسئول']
planned_start date [null, note: 'تاریخ شروع برنامه‌ریزی‌شده']
planned_end date [null, note: 'تاریخ پایان برنامه‌ریزی‌شده']
actual_start date [null, note: 'شروع واقعی']
actual_end date [null, note: 'پایان واقعی']
duration int [not null, default: 1, note: 'مدت برآورد شده (روز کاری)']
progress int [not null, default: 0, note: 'درصد پیشرفت (۰ تا ۱۰۰)']
qc_status varchar(32) [not null, default: 'none', note: 'none, pre_qc_pending, pre_qc_failed, ready, exec_qc_pending, in_progress, qc_failed_rework, final_passed']
human_resources json [not null, default: '[]', note: 'نیروی انسانی: [{role, count}]']
materials json [not null, default: '[]', note: 'مصالح و تجهیزات: [{name, unit, specifications, count}]']
created_at timestamptz [not null, default: `now()`]
updated_at timestamptz [not null, default: `now()`]
indexes {
project_id
parent_id
status
}
}
// ------------------------------------------------------
// 3. Gantt Precedence & CPM Relations
// ------------------------------------------------------
Table task_relations {
id uuid [pk, default: `gen_random_uuid()`]
project_id uuid [not null, note: 'پروژه مربوطه']
from_task_id uuid [not null, note: 'فعالیت پیش‌نیاز (Predecessor)']
to_task_id uuid [not null, note: 'فعالیت پس‌نیاز / وابسته (Successor)']
relation_type varchar(8) [not null, default: 'FS', note: 'FS, SS, FF, SF']
lag_days int [not null, default: 0, note: 'تاخیر یا تقدم مجاز (روز)']
created_at timestamptz [not null, default: `now()`]
indexes {
(from_task_id, to_task_id) [unique]
project_id
}
}
// ------------------------------------------------------
// 4. Quality Control & Inspection
// ------------------------------------------------------
Table qc_templates {
id uuid [pk, default: `gen_random_uuid()`]
name varchar(255) [not null, note: 'نام چک‌لیست الگو']
activity_type varchar(64) [note: 'نوع فعالیت کارگاهی']
description text
created_at timestamptz [not null, default: `now()`]
indexes {
activity_type
}
}
Table qc_questions {
id uuid [pk, default: `gen_random_uuid()`]
template_id uuid [not null]
phase varchar(16) [not null, note: 'مرحله بازرسی: pre, during, after']
text varchar(512) [not null, note: 'متن ضابطه یا سؤال کنترلی']
sort_order int [not null, default: 0]
required boolean [not null, default: true]
indexes {
template_id
}
}
Table task_qc_definitions {
id uuid [pk, default: `gen_random_uuid()`]
task_id uuid [not null, note: 'تسک مربوطه']
name varchar(255) [not null, default: '']
source_template_id uuid [null, note: 'منشأ الگو (جهت ردیابی)']
created_at timestamptz [not null, default: `now()`]
updated_at timestamptz [not null, default: `now()`]
indexes {
task_id
}
}
Table task_qc_questions {
id uuid [pk, default: `gen_random_uuid()`]
definition_id uuid [not null]
phase varchar(16) [not null, note: 'مرحله بازرسی: pre, during, after']
text varchar(512) [not null]
sort_order int [not null, default: 0]
required boolean [not null, default: true]
indexes {
definition_id
}
}
Table task_qc_results {
id uuid [pk, default: `gen_random_uuid()` note: 'ثبت پیوسته تاریخچه ارزیابی بازرسان (Append-Only)']
question_id uuid [not null]
answer varchar(16) [not null, note: 'pass, fail, na']
comment text [note: 'یادداشت یا علت رد']
evaluated_by varchar(255) [not null, default: '', note: 'نام بازرس']
evaluated_at timestamptz [not null, default: `now()`]
indexes {
question_id
}
}
Table defects {
id uuid [pk, default: `gen_random_uuid()`, note: 'عدم انطباق یا عیب کارگاهی']
task_id uuid [not null, note: 'تسک مربوطه']
question_id uuid [null, note: 'سؤال QC منجر به عیب (اختیاری)']
title varchar(512) [not null, note: 'عنوان عدم انطباق']
description text [note: 'شرح ایراد مشاهده‌شده']
severity varchar(16) [not null, default: 'medium', note: 'minor, medium, major, critical']
status varchar(16) [not null, default: 'open', note: 'open, in_progress, resolved']
corrective_action text [note: 'دستور اقدام اصلاحی']
resolution_notes text [note: 'گزارش رفع عیب']
photo_path varchar(512) [null, note: 'مسیر تصویر پیوست عیب']
created_at timestamptz [not null, default: `now()`]
resolved_at timestamptz [null, note: 'زمان تایید رفع عیب']
indexes {
task_id
}
}
// ------------------------------------------------------
// 5. Users & Authentication
// ------------------------------------------------------
Table pm_users {
id uuid [pk, default: `gen_random_uuid()`]
username varchar(64) [unique, not null]
password_hash varchar(255) [not null]
display_name varchar(255) [not null, default: '']
role varchar(32) [not null, default: 'project_manager', note: 'project_manager, project_supervisor']
created_at timestamptz [not null, default: `now()`]
indexes {
username
}
}
// ------------------------------------------------------
// Foreign Key Relationships
// ------------------------------------------------------
Ref: project_members.project_id > projects.id [delete: cascade]
Ref: project_members.user_id > pm_users.id [delete: cascade]
Ref: project_assignments.project_id > projects.id [delete: cascade]
Ref: project_assignments.user_id > pm_users.id [delete: cascade]
Ref: tasks.project_id > projects.id [delete: cascade]
Ref: tasks.parent_id > tasks.id [delete: cascade]
Ref: tasks.assignee_id > pm_users.id [delete: set null]
Ref: task_relations.project_id > projects.id [delete: cascade]
Ref: task_relations.from_task_id > tasks.id [delete: cascade]
Ref: task_relations.to_task_id > tasks.id [delete: cascade]
Ref: qc_questions.template_id > qc_templates.id [delete: cascade]
Ref: task_qc_definitions.task_id > tasks.id [delete: cascade]
Ref: task_qc_definitions.source_template_id > qc_templates.id [delete: set null]
Ref: task_qc_questions.definition_id > task_qc_definitions.id [delete: cascade]
Ref: task_qc_results.question_id > task_qc_questions.id [delete: cascade]
Ref: defects.task_id > tasks.id [delete: cascade]
Ref: defects.question_id > task_qc_questions.id [delete: set null]
+2
View File
@@ -0,0 +1,2 @@
-- PM database bootstrap (tables are created by SQLAlchemy at startup)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
+1226
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
# Start the PM Construction Platform stack (postgres + api + frontend)
# Run this from the PM folder:
#
# powershell -ExecutionPolicy Bypass -File .\start.ps1
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $here
Write-Host ""
Write-Host "==========================================================" -ForegroundColor Cyan
Write-Host " PM Construction Platform — Starting Docker Containers " -ForegroundColor Cyan
Write-Host "==========================================================" -ForegroundColor Cyan
# 1. Verify Docker Engine is running
try {
docker info > $null 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Docker is not running"
}
}
catch {
Write-Host ""
Write-Host " [ERROR] Docker Desktop is not running or the Docker daemon is unreachable." -ForegroundColor Red
Write-Host " Please start Docker Desktop from the Start Menu, wait until it is running, and try again." -ForegroundColor Yellow
Write-Host ""
exit 1
}
# 2. Ensure erp_default network exists so compose never fails if ERP is not running
$erpNet = docker network ls --filter name=^erp_default$ -q
if (-not $erpNet) {
Write-Host "Creating bridge network 'erp_default' for optional ERP integration..." -ForegroundColor Gray
docker network create erp_default | Out-Null
}
# 3. Build & start containers
Write-Host "Starting containers (build if necessary)..." -ForegroundColor Yellow
docker compose up -d --build
if ($LASTEXITCODE -ne 0) {
Write-Host "Build or startup failed. Run 'docker compose logs' to inspect." -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "Waiting for services to become healthy..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
docker compose ps
Write-Host ""
Write-Host "PM Platform UI : http://localhost:8083" -ForegroundColor Green
Write-Host "PM API Health : http://localhost:8083/api/v1/health" -ForegroundColor Green
Write-Host "Database : localhost:5434 (db: pm, user: pm)" -ForegroundColor Green
Write-Host "Default Admin : admin / pm123456" -ForegroundColor Green
Write-Host ""
Write-Host "Stop: docker compose down | Full reset: docker compose down -v" -ForegroundColor DarkGray