commit 934b74ebbd57177184501a9578e93c59c54d2010 Author: MMD Date: Wed Oct 7 15:14:20 2026 +0330 Squashed 'pm/' content from commit 91f3271 git-subtree-dir: pm git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e86cb62 --- /dev/null +++ b/.env.example @@ -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 + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6ebb06a --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8d2c03 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/API_CONTRACT.md b/API_CONTRACT.md new file mode 100644 index 0000000..3367249 --- /dev/null +++ b/API_CONTRACT.md @@ -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 ` می‌خواهند. +- خطاها: `{"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` diff --git a/DATABASE_GUIDE.md b/DATABASE_GUIDE.md new file mode 100644 index 0000000..35de014 --- /dev/null +++ b/DATABASE_GUIDE.md @@ -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):** + تاریخ شروع، پایان و درصد پیشرفت فیزیکی کل پروژه همواره حاصل تجمیع و میانگین‌گیری پویای وظایف موجود در پروژه است. diff --git a/README.md b/README.md new file mode 100644 index 0000000..64bc04d --- /dev/null +++ b/README.md @@ -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) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b943e2c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +.pytest_cache diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1bdf7f6 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a75ed9f --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""PM application package.""" diff --git a/backend/app/authz.py b/backend/app/authz.py new file mode 100644 index 0000000..a8df949 --- /dev/null +++ b/backend/app/authz.py @@ -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] diff --git a/backend/app/casdoor_auth.py b/backend/app/casdoor_auth.py new file mode 100644 index 0000000..831c9c5 --- /dev/null +++ b/backend/app/casdoor_auth.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..681c80a --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..a8c5945 --- /dev/null +++ b/backend/app/db.py @@ -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 diff --git a/backend/app/dev_accounts.py b/backend/app/dev_accounts.py new file mode 100644 index 0000000..d77415e --- /dev/null +++ b/backend/app/dev_accounts.py @@ -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)) diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..ec185af --- /dev/null +++ b/backend/app/errors.py @@ -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) diff --git a/backend/app/integrations/__init__.py b/backend/app/integrations/__init__.py new file mode 100644 index 0000000..0478b42 --- /dev/null +++ b/backend/app/integrations/__init__.py @@ -0,0 +1 @@ +"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here.""" diff --git a/backend/app/integrations/erp_client.py b/backend/app/integrations/erp_client.py new file mode 100644 index 0000000..14fd353 --- /dev/null +++ b/backend/app/integrations/erp_client.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..db2f30e --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..ca242f4 --- /dev/null +++ b/backend/app/models.py @@ -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()) diff --git a/backend/app/qc_service.py b/backend/app/qc_service.py new file mode 100644 index 0000000..487fc0c --- /dev/null +++ b/backend/app/qc_service.py @@ -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 diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..8d4691d --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""PM API routers.""" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..6256645 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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)}} diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..03e88b4 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -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} diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py new file mode 100644 index 0000000..f1050b7 --- /dev/null +++ b/backend/app/routers/files.py @@ -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("فایل یافت نشد") diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..a80a649 --- /dev/null +++ b/backend/app/routers/projects.py @@ -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}} diff --git a/backend/app/routers/qc.py b/backend/app/routers/qc.py new file mode 100644 index 0000000..4197bd3 --- /dev/null +++ b/backend/app/routers/qc.py @@ -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) diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py new file mode 100644 index 0000000..8fda4f6 --- /dev/null +++ b/backend/app/routers/tasks.py @@ -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}} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..f0be383 --- /dev/null +++ b/backend/app/routers/users.py @@ -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)} diff --git a/backend/app/scheduling_service.py b/backend/app/scheduling_service.py new file mode 100644 index 0000000..bf43bbc --- /dev/null +++ b/backend/app/scheduling_service.py @@ -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 + diff --git a/backend/app/schema_sync.py b/backend/app/schema_sync.py new file mode 100644 index 0000000..7389af0 --- /dev/null +++ b/backend/app/schema_sync.py @@ -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) + diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..8a0d306 --- /dev/null +++ b/backend/app/security.py @@ -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 diff --git a/backend/app/serializers.py b/backend/app/serializers.py new file mode 100644 index 0000000..dee0f52 --- /dev/null +++ b/backend/app/serializers.py @@ -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), + } diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..750d336 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities (Jalali dates, ...).""" diff --git a/backend/app/utils/date_service.py b/backend/app/utils/date_service.py new file mode 100644 index 0000000..b2dca83 --- /dev/null +++ b/backend/app/utils/date_service.py @@ -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() diff --git a/backend/app/utils/jalali.py b/backend/app/utils/jalali.py new file mode 100644 index 0000000..5721cc8 --- /dev/null +++ b/backend/app/utils/jalali.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a7d2fde --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_api_gantt_scheduling.py b/backend/tests/test_api_gantt_scheduling.py new file mode 100644 index 0000000..c3ec0bf --- /dev/null +++ b/backend/tests/test_api_gantt_scheduling.py @@ -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() diff --git a/backend/tests/test_api_projects_tasks.py b/backend/tests/test_api_projects_tasks.py new file mode 100644 index 0000000..292c5b5 --- /dev/null +++ b/backend/tests/test_api_projects_tasks.py @@ -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() diff --git a/backend/tests/test_date_service.py b/backend/tests/test_date_service.py new file mode 100644 index 0000000..940dfb8 --- /dev/null +++ b/backend/tests/test_date_service.py @@ -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 diff --git a/backend/tests/test_lifecycle_dates.py b/backend/tests/test_lifecycle_dates.py new file mode 100644 index 0000000..e04c75e --- /dev/null +++ b/backend/tests/test_lifecycle_dates.py @@ -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 diff --git a/backend/tests/test_projects_and_tasks.py b/backend/tests/test_projects_and_tasks.py new file mode 100644 index 0000000..4144f10 --- /dev/null +++ b/backend/tests/test_projects_and_tasks.py @@ -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 + + + diff --git a/backend/tests/test_scheduling_service.py b/backend/tests/test_scheduling_service.py new file mode 100644 index 0000000..3a5a319 --- /dev/null +++ b/backend/tests/test_scheduling_service.py @@ -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"] diff --git a/backend/tools/__init__.py b/backend/tools/__init__.py new file mode 100644 index 0000000..11881d1 --- /dev/null +++ b/backend/tools/__init__.py @@ -0,0 +1 @@ +"""PM backend tools (seed etc.).""" diff --git a/backend/tools/seed.py b/backend/tools/seed.py new file mode 100644 index 0000000..b5c19b3 --- /dev/null +++ b/backend/tools/seed.py @@ -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()) diff --git a/backend/tools/seed_erp_service.py b/backend/tools/seed_erp_service.py new file mode 100644 index 0000000..1e2273e --- /dev/null +++ b/backend/tools/seed_erp_service.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b0af23a --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..d1e9f71 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.log +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f1f746e --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1aa6153 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + PM — مدیریت پروژه + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1383cb5 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7f915f5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2650 @@ +{ + "name": "pm-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pm-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cb584ce --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f9128dc --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + + + ); +} + +function LoadingScreen() { + return ( + +
+ در حال بارگذاری... +
+
+ ); +} + +/** + * نگهبان مسیرها: توکن لازم است و کاربر باید اطلاعات نقش/مجوز داشته باشد. + * نشست‌های ذخیره‌شده پیش از 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(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 ; + } + return ; + } + if (sessionNeedsRefresh(user)) return ; + return ; +} + +/** مسیرهایی که فقط با مجوز مشخص باز می‌شوند (سرور هم مستقل بررسی می‌کند) */ +function RequirePermission({ permission, children }: { permission: string; children: ReactNode }) { + const user = useAuthStore((s) => s.user); + if (!hasPermission(user, permission)) return ; + return <>{children}; +} + +export default function App() { + return ( + + + } /> + }> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + + + + } + /> + } /> + + } /> + + + ); +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ca3e3e2 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+ +
+
+

خطای غیرمنتظره

+

+ مشکلی در نمایش برنامه پیش آمد. لطفا دوباره تلاش کنید. +

+ {this.state.error && ( +
+

+ {this.state.error.name}: {this.state.error.message} +

+
+ )} +
+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/GanttChart.tsx b/frontend/src/components/GanttChart.tsx new file mode 100644 index 0000000..40b8dfc --- /dev/null +++ b/frontend/src/components/GanttChart.tsx @@ -0,0 +1,1330 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + Calendar, + CalendarDays, + CheckCircle2, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Clock, + Flame, + Hand, + Layers, + Link2, + Pencil, + Plus, + RefreshCw, + Search, + Trash2, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { toast } from "sonner"; +import { + createTaskRelation, + type CreateRelationPayload, + type GanttDataset, + type GanttTask, + type Task, + type TaskRelation, +} from "../lib/api"; +import { + gregorianToJalali, + MONTHS, + toFa, +} from "../lib/jalali"; +import { formatDate } from "../lib/format"; +import { Card, Chip, ProgressBar } from "./ui"; + +export interface GanttChartProps { + data: GanttDataset; + projectId?: string; + allTasks?: Task[]; + onEditTask?: (task: Task | GanttTask) => void; + onAddRelation?: (fromTaskId: string) => void; + onEditRelation?: (relation: TaskRelation) => void; + onDeleteRelation?: (relationId: string) => void; + onReschedule?: () => void; + isRescheduling?: boolean; + canManageTasks?: boolean; + canManageProjects?: boolean; +} + +function getPriorityInfo(priority?: string | null) { + switch ((priority || "").toLowerCase()) { + case "urgent": + return { label: "فوری", desc: "درجه اهمیت = ۱۳", tone: "red" as const }; + case "high": + return { label: "بالا", desc: "درجه اهمیت = ۵", tone: "red" as const }; + case "normal": + case "medium": + return { label: "عادی", desc: "درجه اهمیت = ۲", tone: "sky" as const }; + case "low": + return { label: "پایین", desc: "درجه اهمیت = ۱", tone: "slate" as const }; + default: + return { label: "عادی", desc: "درجه اهمیت = ۲", tone: "slate" as const }; + } +} + + +interface TimelineDay { + dateObj: Date; + dateKey: string; + jy: number; + jm: number; + jd: number; + isFriday: boolean; + isToday: boolean; +} + +// Zoom thresholds — colWidth pixel ranges determine what granularity to show +// Min: 2px/day → ~1 year visible at once +// Max: 54px/day → very detailed daily view +const ZOOM_MIN = 2; +const ZOOM_MAX = 54; +const ZOOM_DEFAULT = 28; + +// Neutral color palette for task bars — one single grey for every bar, and for +// every status (critical / complete / overdue), so the chart reads as one +// consistent grid regardless of how many tasks are on screen. +const BAR_COLORS: { bg: string; border: string; ring: string; progress: string }[] = [ + { bg: "bg-gradient-to-r from-slate-500 to-slate-600", border: "border-slate-600", ring: "ring-slate-400", progress: "bg-slate-900/35" }, +]; + +function getBarColor(_index: number) { + return BAR_COLORS[0]; +} + +/** + * یک ردیف وابستگی در کشوی تسک. + * + * عنوان نمایش‌داده‌شده همیشه «فعالیت دیگر» است: برای پیش‌نیاز، تسک مبدأ و + * برای پس‌نیاز، تسک مقصد — نه تسک انتخاب‌شده. اگر عنوان از سرور نیامده باشد + * از فهرست تسک‌ها پر می‌شود تا ردیف هیچ‌وقت بی‌نام نماند. + */ +function RelationRow({ + title, + kindLabel, + kindTone, + relation, + canManage, + onEdit, + onDelete, +}: { + title: string; + kindLabel: string; + kindTone: "sky" | "slate"; + relation: TaskRelation; + canManage: boolean; + onEdit?: (relation: TaskRelation) => void; + onDelete?: (relationId: string) => void; +}) { + return ( +
+
+ {kindLabel} + {title} +
+
+ + {relation.relation_type} + {relation.lag_days ? ` +${toFa(relation.lag_days)}ر` : ""} + + {canManage && onEdit && ( + + )} + {canManage && onDelete && ( + + )} +
+
+ ); +} + +export function GanttChart({ + data, + projectId = "", + allTasks = [], + onEditTask, + onAddRelation, + onEditRelation, + onDeleteRelation, + onReschedule, + isRescheduling = false, + canManageTasks = true, + canManageProjects = true, +}: GanttChartProps) { + const queryClient = useQueryClient(); + + const [colWidth, setColWidth] = useState(ZOOM_DEFAULT); + const [criticalOnly, setCriticalOnly] = useState(false); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [zoomPanelOpen, setZoomPanelOpen] = useState(false); + const zoomPanelRef = useRef(null); + + useEffect(() => { + if (!zoomPanelOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (zoomPanelRef.current && !zoomPanelRef.current.contains(e.target as Node)) { + setZoomPanelOpen(false); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setZoomPanelOpen(false); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [zoomPanelOpen]); + + // Quick relation add state in drawer + const [showAddRelationInline, setShowAddRelationInline] = useState(false); + const [newRelDirection, setNewRelDirection] = useState<"predecessor" | "successor">("predecessor"); + const [newRelTargetId, setNewRelTargetId] = useState(""); + const [newRelType, setNewRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS"); + const [newRelLag, setNewRelLag] = useState(0); + const [relError, setRelError] = useState(""); + + const containerRef = useRef(null); + /** ناحیهٔ اسکرول افقی نمودار — برای پرش به امروز و پیمایش Panning */ + const scrollRef = useRef(null); + const touchDistanceRef = useRef(null); + + // حالت‌های پیمایش دستی (Panning) برای کاربر دسکتاپ و موبایل + const [isPanMode, setIsPanMode] = useState(false); + const [isSpacePressed, setIsSpacePressed] = useState(false); + const [isPanning, setIsPanning] = useState(false); + + const mousePanRef = useRef<{ + isDown: boolean; + startX: number; + scrollLeft: number; + hasMoved: boolean; + }>({ + isDown: false, + startX: 0, + scrollLeft: 0, + hasMoved: false, + }); + + const touchPanRef = useRef<{ + startX: number; + startY: number; + scrollLeft: number; + hasMoved: boolean; + } | null>(null); + + const wasPanningRef = useRef(false); + const clearWasPanningTimerRef = useRef | null>(null); + + // کلید Space برای پیمایش موقت و کلید H برای تغییر وضعیت حالت دست (Pan Mode) + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return; + + if (e.code === "Space" && !e.repeat) { + e.preventDefault(); + setIsSpacePressed(true); + } else if ((e.key === "h" || e.key === "H") && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + setIsPanMode((prev) => !prev); + } + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") { + setIsSpacePressed(false); + } + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, []); + + // گوش دادن به جابجایی ماوس در سطح صفحه برای درگ روان نمودار (Drag-to-Pan) + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!mousePanRef.current.isDown) return; + const el = scrollRef.current; + if (!el) return; + + const dx = e.clientX - mousePanRef.current.startX; + if (!mousePanRef.current.hasMoved) { + if (Math.abs(dx) > 4) { + mousePanRef.current.hasMoved = true; + wasPanningRef.current = true; + setIsPanning(true); + } + } + + if (mousePanRef.current.hasMoved) { + el.scrollLeft = mousePanRef.current.scrollLeft - dx; + } + }; + + const onMouseUp = () => { + if (mousePanRef.current.isDown) { + mousePanRef.current.isDown = false; + setIsPanning(false); + if (mousePanRef.current.hasMoved) { + if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current); + clearWasPanningTimerRef.current = setTimeout(() => { + wasPanningRef.current = false; + mousePanRef.current.hasMoved = false; + }, 80); + } + } + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current); + }; + }, []); + + const { tasks, relations, critical_path, project } = data; + + // --------------------------------------------------------------- + // FILTER: only show task_type === 'task' (وظیفه اجرایی) in Gantt + // --------------------------------------------------------------- + const execTasks = useMemo( + () => tasks.filter((t) => !t.task_type || t.task_type === "task"), + [tasks] + ); + + // Selected task object (from execTasks only) + const selectedTask = useMemo(() => { + if (!selectedTaskId) return null; + return execTasks.find((t) => t.id === selectedTaskId) || null; + }, [execTasks, selectedTaskId]); + + // Sync drawer state when selectedTask changes + useEffect(() => { + if (selectedTask) { + setShowAddRelationInline(false); + setRelError(""); + } + }, [selectedTask]); + + // Filter tasks if criticalOnly + const displayTasks = useMemo(() => { + if (!criticalOnly) return execTasks; + return execTasks.filter((t) => t.is_critical || (t.id && critical_path.includes(t.id))); + }, [execTasks, criticalOnly, critical_path]); + + // Date boundaries + const { minDate, totalDays } = useMemo(() => { + const dates: Date[] = []; + + if (project.planned_start) dates.push(new Date(project.planned_start)); + if (project.planned_end) dates.push(new Date(project.planned_end)); + + for (const t of execTasks) { + if (t.planned_start) dates.push(new Date(t.planned_start)); + if (t.planned_end) dates.push(new Date(t.planned_end)); + if (t.start_date) dates.push(new Date(t.start_date)); + if (t.due_date) dates.push(new Date(t.due_date)); + } + + const today = new Date(); + dates.push(today); + + if (dates.length === 0) { + const d = new Date(); + return { minDate: d, totalDays: 365 }; + } + + let min = new Date(Math.min(...dates.map((d) => d.getTime()))); + let max = new Date(Math.max(...dates.map((d) => d.getTime()))); + + min = new Date(min.getFullYear(), min.getMonth(), min.getDate()); + max = new Date(max.getFullYear(), max.getMonth(), max.getDate()); + + // Always extend so the chart spans at least 1 full year, with margins + const yearStart = new Date(min.getFullYear(), 0, 1); + const yearEnd = new Date(max.getFullYear(), 11, 31); + if (yearStart < min) min = yearStart; + if (yearEnd > max) max = yearEnd; + + min = new Date(min.getTime() - 10 * 86400000); + max = new Date(max.getTime() + 10 * 86400000); + + const days = Math.ceil(Math.abs(max.getTime() - min.getTime()) / 86400000) + 1; + return { minDate: min, totalDays: Math.max(days, 365) }; + }, [execTasks, project]); + + // Build timeline days + const timelineDays: TimelineDay[] = useMemo(() => { + const result: TimelineDay[] = []; + const today = new Date(); + const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + + for (let i = 0; i < totalDays; i++) { + const d = new Date(minDate.getTime() + i * 86400000); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); + result.push({ + dateObj: d, + dateKey, + jy, + jm, + jd, + isFriday: d.getDay() === 5, + isToday: dateKey === todayKey, + }); + } + return result; + }, [minDate, totalDays]); + + // Jalali month groups for header + const monthGroups = useMemo(() => { + const groups: { label: string; shortLabel: string; count: number }[] = []; + let currentKey = ""; + let currentCount = 0; + let currentName = ""; + let currentShort = ""; + + for (const day of timelineDays) { + const key = `${day.jy}-${day.jm}`; + const name = `${MONTHS[day.jm - 1]} ${toFa(day.jy)}`; + const short = MONTHS[day.jm - 1]; + if (key !== currentKey) { + if (currentKey) groups.push({ label: currentName, shortLabel: currentShort, count: currentCount }); + currentKey = key; + currentName = name; + currentShort = short; + currentCount = 1; + } else { + currentCount++; + } + } + if (currentKey) groups.push({ label: currentName, shortLabel: currentShort, count: currentCount }); + return groups; + }, [timelineDays]); + + // Jalali year groups for ultra-zoomed-out header + const yearGroups = useMemo(() => { + const groups: { label: string; count: number }[] = []; + let currentYear = 0; + let currentCount = 0; + for (const day of timelineDays) { + if (day.jy !== currentYear) { + if (currentYear) groups.push({ label: toFa(currentYear), count: currentCount }); + currentYear = day.jy; + currentCount = 1; + } else { + currentCount++; + } + } + if (currentYear) groups.push({ label: toFa(currentYear), count: currentCount }); + return groups; + }, [timelineDays]); + + const totalTimelineWidth = totalDays * colWidth; + + // Zoom level determines header granularity + // < 6px/day → show years only + // 6-18px/day → show months only + // > 18px/day → show months + individual days + const showDays = colWidth >= 18; + const showMonths = colWidth >= 6; + + const getDayDiff = useCallback( + (dateStr: string | null | undefined): number => { + if (!dateStr) return 0; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return 0; + const cleanDate = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return Math.round((cleanDate.getTime() - minDate.getTime()) / 86400000); + }, + [minDate] + ); + + const getTaskBarCoords = useCallback( + (task: GanttTask) => { + const rawStart = task.planned_start || task.start_date; + const rawEnd = task.planned_end || task.due_date; + + const startDiff = getDayDiff(rawStart); + // فاصلهٔ روزها (روز پایان حساب نمی‌شود) — با سرور و فرم‌ها یکسان است + const durationDays = Math.max( + task.duration || + task.duration_days || + (rawEnd && rawStart ? getDayDiff(rawEnd) - getDayDiff(rawStart) : 0), + 0 + ); + + const left = Math.max(startDiff * colWidth, 0); + const width = Math.max(durationDays * colWidth - 4, 16); + + return { left, width, startDiff, durationDays }; + }, + [getDayDiff, colWidth] + ); + + // One grey for every bar — status no longer changes the colour. + const getBarMeta = (task: GanttTask, index: number) => { + const isCritical = task.is_critical || (task.id && critical_path.includes(task.id)); + const progressVal = task.progress ?? task.percentage_done ?? 0; + const isComplete = progressVal >= 100 || task.status === "COMPLETED"; + const rawEnd = task.planned_end || task.due_date; + const isOverdue = rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0); + const bar = getBarColor(index); + + if (isCritical) { + return { ...bar, tag: "مسیر بحرانی", tone: "red" as const, icon: Flame }; + } + if (isComplete) { + return { ...bar, tag: "تکمیل شده", tone: "green" as const, icon: CheckCircle2 }; + } + if (isOverdue) { + return { ...bar, tag: "تاخیر دارد", tone: "amber" as const, icon: AlertTriangle }; + } + return { + ...bar, + tag: progressVal > 0 ? "در حال انجام" : "برنامه‌ریزی شده", + tone: "sky" as const, + icon: progressVal > 0 ? Clock : Calendar, + }; + }; + + // ------------ Zoom controls ------------ + const handleZoomIn = () => setColWidth((prev) => Math.min(prev + 4, ZOOM_MAX)); + const handleZoomOut = () => setColWidth((prev) => Math.max(prev - 4, ZOOM_MIN)); + + const handleWheel = (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), ZOOM_MIN), ZOOM_MAX)); + } else if (e.shiftKey && e.deltaY && !e.deltaX && scrollRef.current) { + scrollRef.current.scrollLeft += e.deltaY; + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + // کلیک چپ یا کلیک با غلتک میانی ماوس + if (e.button !== 0 && e.button !== 1) return; + const el = scrollRef.current; + if (!el) return; + + mousePanRef.current = { + isDown: true, + startX: e.clientX, + scrollLeft: el.scrollLeft, + hasMoved: false, + }; + + if (e.button === 1 || isPanMode || isSpacePressed) { + e.preventDefault(); + } + }; + + const handleTouchStart = (e: React.TouchEvent) => { + if (e.touches.length === 2) { + // زوم دو انگشتی (Pinch to zoom) + touchDistanceRef.current = Math.hypot( + e.touches[1].clientX - e.touches[0].clientX, + e.touches[1].clientY - e.touches[0].clientY + ); + touchPanRef.current = null; + } else if (e.touches.length === 1) { + // پیمایش تک‌انگشتی لمسی + const t = e.touches[0]; + const el = scrollRef.current; + touchDistanceRef.current = null; + touchPanRef.current = { + startX: t.clientX, + startY: t.clientY, + scrollLeft: el?.scrollLeft || 0, + hasMoved: false, + }; + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (e.touches.length === 2 && touchDistanceRef.current !== null) { + const dist = Math.hypot( + e.touches[1].clientX - e.touches[0].clientX, + e.touches[1].clientY - e.touches[0].clientY + ); + const delta = dist - touchDistanceRef.current; + if (Math.abs(delta) > 6) { + setColWidth((prev) => Math.min(Math.max(prev + (delta > 0 ? 2 : -2), ZOOM_MIN), ZOOM_MAX)); + touchDistanceRef.current = dist; + } + } else if (e.touches.length === 1 && touchPanRef.current) { + const t = e.touches[0]; + const dx = t.clientX - touchPanRef.current.startX; + const dy = t.clientY - touchPanRef.current.startY; + if (Math.hypot(dx, dy) > 6) { + touchPanRef.current.hasMoved = true; + wasPanningRef.current = true; + } + } + }; + + const handleTouchEnd = () => { + touchDistanceRef.current = null; + if (touchPanRef.current?.hasMoved) { + if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current); + clearWasPanningTimerRef.current = setTimeout(() => { + wasPanningRef.current = false; + if (touchPanRef.current) touchPanRef.current.hasMoved = false; + }, 100); + } + }; + + const handlePanBy = (offset: number) => { + const el = scrollRef.current; + if (!el) return; + el.scrollBy({ left: offset, behavior: "smooth" }); + }; + + // ------------ Relations: inline create ------------ + const availableTargetTasks = allTasks.length > 0 ? allTasks : execTasks; + + const createRelMutation = useMutation({ + mutationFn: (payload: CreateRelationPayload) => createTaskRelation(projectId, payload), + onSuccess: () => { + toast.success("وابستگی با موفقیت ایجاد شد"); + void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] }); + void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] }); + setShowAddRelationInline(false); + setNewRelTargetId(""); + setNewRelLag(0); + setRelError(""); + }, + onError: (err: any) => setRelError(err?.message || "خطا در ایجاد وابستگی"), + }); + + const handleCreateRelation = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedTask || !newRelTargetId) { setRelError("لطفاً فعالیت مرتبط را انتخاب کنید"); return; } + if (newRelTargetId === selectedTask.id) { setRelError("یک فعالیت نمی‌تواند وابسته به خودش باشد"); return; } + const fromId = newRelDirection === "predecessor" ? newRelTargetId : selectedTask.id!; + const toId = newRelDirection === "predecessor" ? selectedTask.id! : newRelTargetId; + createRelMutation.mutate({ from_task_id: fromId, to_task_id: toId, relation_type: newRelType, lag_days: Number(newRelLag) || 0 }); + }; + + const todayIndex = timelineDays.findIndex((d) => d.isToday); + + /** + * وابستگی‌های تسک انتخاب‌شده، یک‌جا: پیش‌نیازها اول و بعد پس‌نیازها. + * + * عنوان هر ردیف «فعالیت دیگر» است — برای پیش‌نیاز تسک مبدأ و برای پس‌نیاز + * تسک مقصد. اگر سرور عنوان را نداده باشد از فهرست تسک‌ها پر می‌شود، پس + * ردیف هیچ‌وقت بی‌نام نمایش داده نمی‌شود. + */ + const { myPredecessors, mySuccessors } = useMemo(() => { + if (!selectedTask) return { myPredecessors: [], mySuccessors: [] }; + const titleOf = (id: string, fallback?: string) => + fallback || execTasks.find((t) => t.id === id)?.title || execTasks.find((t) => t.id === id)?.subject || "بدون عنوان"; + + return { + myPredecessors: relations + .filter((r) => r.to_task_id === selectedTask.id) + .map((relation) => ({ relation, title: titleOf(relation.from_task_id, relation.from_task_title) })), + mySuccessors: relations + .filter((r) => r.from_task_id === selectedTask.id) + .map((relation) => ({ relation, title: titleOf(relation.to_task_id, relation.to_task_title) })), + }; + }, [relations, selectedTask, execTasks]); + + /** + * اسکرول افقی به امروز، طوری که نشانگر امروز حدود یکسوم از سمت چپ دیده شود + * (نه دقیقاً چسبیده به لبه، تا کارهای بعد از امروز هم دیده شوند). + * + * ظرف نمودار عمداً `dir="ltr"` است (نوار زمان چپ به راست)، پس `scrollLeft` + * مثل LTR معمولی از ۰ به سمت مثبت می‌رود و نیازی به تصحیح منفی نیست. + * مقدار را با offset واقعی همان لحظه حساب می‌کنیم تا اگر جهت عوض شد، + * جابه‌جایی درست بماند. + */ + const scrollToToday = () => { + const el = scrollRef.current; + if (!el || todayIndex === -1) return; + const todayX = todayIndex * colWidth + colWidth / 2; + const target = Math.max(0, todayX - el.clientWidth / 3); + const maxScroll = el.scrollWidth - el.clientWidth; + el.scrollTo({ left: Math.min(target, maxScroll), behavior: "smooth" }); + }; + + // ================================================================ + // RENDER + // ================================================================ + return ( +
+ + {/* -------- Top Controls Bar -------- */} +
+
+ + + + {toFa(execTasks.length)} وظیفه اجرایی + + {isPanMode && ( + <> + + + + حالت پیمایش فعال است (H) + + + )} +
+ +
+ {/* Zoom controls */} +
+ + + {/* Zoom Slider Panel */} + {zoomPanelOpen && ( +
+ + + {/* Tall track so the whole zoom range is easy to grab and fine-tune */} +
+ setColWidth(Number(e.target.value))} + aria-label="زوم گانت" + className="zoom-range cursor-pointer" + /> +
+ + + + + + {/* دکمه‌های پیمایش سریع به چپ و راست */} +
+
+ + +
+ + {/* Zoom level label */} + + {colWidth < 6 ? "سال" : colWidth < 18 ? "ماه" : "روز"} + +
+ )} +
+ + {/* Today button */} + + + {/* Pan mode button */} + + + {onReschedule && ( + + )} +
+
+ + {/* -------- Gantt Canvas Card -------- */} + + + {/* ---- Scrollable Timeline ---- */} + {/* ---- Scrollable Timeline ---- + dir="ltr" عمدی است: نوار زمان باید از چپ به راست پیش برود (قدیمی‌تر + سمت چپ، جدیدتر سمت راست) — مثل همهٔ گانت‌چارت‌ها. بدون این، چیدمان + راست‌به‌چپ کل برنامه زمان را برعکس می‌کرد. موقعیت نوارها و خط امروز + با left/width محاسبه می‌شوند، پس با ltr درست می‌نشینند. */} +
+
+ + {/* ---- Calendar Header ---- */} +
+ + {/* Year row — always shown */} + {!showMonths && ( +
+ {yearGroups.map((yg, i) => ( +
+ {yg.label} +
+ ))} +
+ )} + + {/* Month row — shown when zoom >= 6px/day */} + {showMonths && ( +
+ {monthGroups.map((mg, i) => ( +
+ {mg.count * colWidth > 40 ? mg.label : mg.count * colWidth > 20 ? mg.shortLabel : ""} +
+ ))} +
+ )} + + {/* Day row — shown when zoom >= 18px/day */} + {showDays && ( +
+ {timelineDays.map((day, i) => ( +
+ {toFa(day.jd)} +
+ ))} +
+ )} +
+ + {/* ---- Grid & Bars ---- */} +
+ + {/* Background columns */} +
+ {timelineDays.map((day, i) => ( +
+ ))} +
+ + {/* Today line — kept unlabelled on purpose */} + {todayIndex !== -1 && ( +
+ )} + + {/* ---- Task Rows ---- */} + {displayTasks.length === 0 && ( +
+ هیچ وظیفه اجرایی برای نمایش وجود ندارد +
+ )} + + {displayTasks.map((t, idx) => { + const coords = getTaskBarCoords(t); + const isSelected = selectedTaskId === t.id; + const barMeta = getBarMeta(t, idx); + const progressVal = t.progress ?? t.percentage_done ?? 0; + + return ( +
+ {coords && ( + <> + {/* The Gantt Bar */} +
{ + if ( + isPanMode || + isSpacePressed || + wasPanningRef.current || + mousePanRef.current.hasMoved || + touchPanRef.current?.hasMoved + ) { + e.stopPropagation(); + return; + } + setSelectedTaskId(t.id || null); + setDrawerOpen(true); + }} + className={`absolute h-5 rounded-md shadow border flex items-center overflow-hidden transition-all hover:shadow-md hover:brightness-105 ${barMeta.bg} ${barMeta.border} ${ + isPanning + ? "cursor-grabbing" + : isPanMode || isSpacePressed + ? "cursor-grab" + : "cursor-pointer" + } ${isSelected ? `ring-2 ring-offset-1 ${barMeta.ring}` : ""}`} + title={`${t.title || t.subject} (${toFa(progressVal)}٪)`} + > + {/* Progress fill */} + {progressVal > 0 && ( +
+ )} +
+ + {/* External label: task title + percent, positioned right after the bar */} +
+ + {t.title || t.subject} + {progressVal > 0 && ( + — {toFa(progressVal)}٪ + )} + +
+ + )} +
+ ); + })} +
+
+
+ + + {/* -------- Contextual Bottom Drawer -------- */} + {selectedTask && ( +
+ + + {/* Drawer Header */} +
+
+ +
+
+

{selectedTask.title || selectedTask.subject}

+ {selectedTask.is_critical && مسیر بحرانی} + {(selectedTask.progress ?? selectedTask.percentage_done ?? 0) >= 100 ? "تکمیل شده" : (selectedTask.progress ?? selectedTask.percentage_done ?? 0) > 0 ? "در حال انجام" : "برنامه‌ریزی شده"} +
+
+
+
+ {canManageTasks && onEditTask && ( + + )} + +
+
+ + {/* Drawer Body */} + {drawerOpen && (() => { + const priorityObj = getPriorityInfo(selectedTask.priority); + const progressVal = selectedTask.progress ?? selectedTask.percentage_done ?? 0; + const parentTask = allTasks.find((t) => String(t.id || t.op_wp_id) === String(selectedTask.parent_id || selectedTask.parent_op_wp_id)); + const parentTitle = parentTask ? (parentTask.title || parentTask.subject) : (selectedTask.parent_title || "ندارد (وظیفه ریشه)"); + const matList = selectedTask.materials || []; + + return ( +
+ + {/* Dependencies (Fully Editable) */} +
+
+ + + وابستگی‌ها (پیش‌نیاز و پس‌نیاز) + + {canManageTasks && onAddRelation && ( + + )} +
+ + {/* Inline relation form */} + {showAddRelationInline && ( +
+
+ تعریف وابستگی جدید: + +
+ {relError &&
{relError}
} +
+
+ + +
+
+ + +
+
+ + +
+
+ + setNewRelLag(Number(e.target.value))} className="h-9 w-full rounded-lg border border-slate-300 bg-white px-2 text-xs" /> +
+
+
+ + +
+
+ )} + + {/* Predecessors & Successors — one unified list */} +
+
+ + پیش‌نیاز و پس‌نیازها +
+ + {myPredecessors.length === 0 && mySuccessors.length === 0 ? ( +

وابستگی‌ای تعریف نشده است

+ ) : ( +
+ {/* پیش‌نیازها */} + {myPredecessors.map(({ relation, title }) => ( + + ))} + + {/* پس‌نیازها — بلافاصله بعد از پیش‌نیازها */} + {mySuccessors.map(({ relation, title }) => ( + + ))} +
+ )} +
+
+ + {/* Full Task Details (Read-Only) */} +
+
+ + + مشخصات و اطلاعات وظیفه + + + (غیرقابل ویرایش مستقیم؛ برای تغییر از دکمه «ویرایش وظیفه» بالا استفاده کنید) + +
+ + {/* Grid: Dates, Duration, Progress */} +
+ {/* تاریخ شروع */} +
+
+ + تاریخ شروع +
+ + {formatDate(selectedTask.planned_start || selectedTask.start_date)} + +
+ + {/* تاریخ پایان */} +
+
+ + تاریخ پایان +
+ + {formatDate(selectedTask.planned_end || selectedTask.due_date)} + +
+ + {/* مدت زمان */} +
+
+ + مدت زمان +
+ + {toFa(selectedTask.duration ?? selectedTask.duration_days ?? 0)} روز + +
+ + {/* درصد پیشرفت */} +
+
+ درصد پیشرفت + {toFa(progressVal)}٪ +
+ +
+
+ + {/* Grid: Parent, Priority */} +
+ {/* وظیفه والد */} +
+
وظیفه والد
+ + {parentTitle} + +
+ + {/* اولویت */} +
+
اولویت
+
+ + {priorityObj.label} + +
+
+
+ + {/* توضیحات در صورت وجود */} + {Boolean(selectedTask.description?.trim()) && ( +
+
توضیحات وظیفه:
+

+ {selectedTask.description} +

+
+ )} + + {/* تجهیزات و مصالح */} +
+
+
+ + تجهیزات و مصالح + {matList.length > 0 && ( + + {matList.length} + + )} +
+ {matList.length === 0 ? ( +

تجهیزات یا مصالحی تعیین نشده است

+ ) : ( +
+ {matList.map((item, idx) => ( +
+
+ {item.name} + + {item.count} {item.unit || "واحد"} + +
+ {item.specifications && item.specifications.length > 0 && ( +
+ {item.specifications.map((spec: string, sIdx: number) => ( + + {spec} + + ))} +
+ )} +
+ ))} +
+ )} +
+
+
+
+ ); + })()} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/JalaliDatePicker.tsx b/frontend/src/components/JalaliDatePicker.tsx new file mode 100644 index 0000000..b56e9a9 --- /dev/null +++ b/frontend/src/components/JalaliDatePicker.tsx @@ -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(() => isoToJalaliStr(value)); + + // مقداری که خودمان تولید کرده‌ایم — تا وقتی والد از بیرون مقدار را عوض کند، متن همگام شود + const emittedRef = useRef(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(() => 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 ( +
+ 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 && ( +
+
+ + + + {MONTHS[curJ[1] - 1]} {toFa(curJ[0])} + + + +
+ +
+ {WEEKDAYS.map((w) => ( +
+ {w} +
+ ))} + {grid.map((cell, i) => + cell.d === 0 ? ( +
+ ) : ( + + ), + )} +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ResourceSectionInputs.tsx b/frontend/src/components/ResourceSectionInputs.tsx new file mode 100644 index 0000000..6e6fe0e --- /dev/null +++ b/frontend/src/components/ResourceSectionInputs.tsx @@ -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 ( +
+
+ 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" + /> + +
+ + {tags.length > 0 && ( +
+ {tags.map((tag, i) => ( + + {tag} + + + ))} +
+ )} +
+ ); +} + +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 ( +
+
+ + + مصالح و تجهیزات وظیفه + + {materials.length > 0 && ( + + {materials.length} قلم + + )} +
+ + {/* --- Materials & Equipment Content --- */} +
+ {materials.length === 0 ? ( +

+ هیچ مصالح یا تجهیزاتی تعریف نشده است. +

+ ) : ( + materials.map((m, i) => ( +
+
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+ +
+ + updateMaterial(i, "specifications", tags)} + placeholder="مثال: استاندارد A3، عیار ۳۵۰، بوم ۳۶ متری (Enter بزنید)" + /> +
+
+ )) + )} + + +
+
+ ); +} diff --git a/frontend/src/components/TaskEditModal.tsx b/frontend/src/components/TaskEditModal.tsx new file mode 100644 index 0000000..1b4780e --- /dev/null +++ b/frontend/src/components/TaskEditModal.tsx @@ -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 { + const descendants = new Set(); + const childrenMap = new Map(); + 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(null); + const [plannedEnd, setPlannedEnd] = useState(null); + const [durationDays, setDurationDays] = useState(""); + const [progress, setProgress] = useState(""); + const [humanResources, setHumanResources] = useState([]); + const [materials, setMaterials] = useState([]); + 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(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 ( + +
+ {errorMsg && ( +
+ {errorMsg} +
+ )} + + {showConfirmDelete && ( +
+
+ + تأیید حذف وظیفه +
+

+ آیا از حذف «{task.title || task.subject}» و تمام زیرتسک‌های مرتبط با آن اطمینان دارید؟ +

+
+ + +
+
+ )} + +
+ + setTitle(e.target.value)} + placeholder="عنوان وظیفه" + required + className={inputCls} + /> +
+ +
+ + +
+ +
+ + { + const raw = e.target.value; + setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw)))); + }} + placeholder="۰" + className={`${inputCls} text-left`} + /> +
+ +
+
+ + +
+
+ + +
+
+ + { + const next = reconcileDates( + { start: iso, end: plannedEnd, days: durationDays }, + "start", + ); + setPlannedStart(next.start); + setPlannedEnd(next.end); + setDurationDays(next.days); + }} + placeholder="انتخاب تاریخ" + /> +
+
+ + { + const next = reconcileDates( + { start: plannedStart, end: iso, days: durationDays }, + "end", + ); + setPlannedStart(next.start); + setPlannedEnd(next.end); + setDurationDays(next.days); + }} + placeholder="انتخاب تاریخ" + /> +
+
+ +
+

تاریخ‌های واقعی (محاسبه و ثبت خودکار):

+
+
+ شروع واقعی: + + {(task as any)?.actual_start_jalali || ((task as any)?.actual_start ? formatDate((task as any)?.actual_start) : "شروع نشده")} + +
+
+ پایان واقعی: + + {(task as any)?.actual_end_jalali || ((task as any)?.actual_end ? formatDate((task as any)?.actual_end) : "تکمیل نشده")} + +
+
+
+ +
+ + { + 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} + /> +

+ روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد. +

+
+ +
+ +