chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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`
|
||||
@@ -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):**
|
||||
تاریخ شروع، پایان و درصد پیشرفت فیزیکی کل پروژه همواره حاصل تجمیع و میانگینگیری پویای وظایف موجود در پروژه است.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""PM application package."""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""PM API routers."""
|
||||
@@ -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)}}
|
||||
@@ -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}
|
||||
@@ -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("فایل یافت نشد")
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
@@ -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}}
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared utilities (Jalali dates, ...)."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""PM backend tools (seed etc.)."""
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2650
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
// آدرس سامانه ERP (تدارکات/انبار) برای منوی «پنلها» — خالی = پیشفرض لوکال
|
||||
readonly VITE_ERP_URL?: string;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
@@ -0,0 +1,2 @@
|
||||
-- PM database bootstrap (tables are created by SQLAlchemy at startup)
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
+1226
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user