diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml
new file mode 100644
index 0000000..1330882
--- /dev/null
+++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml
@@ -0,0 +1,3 @@
+title: "Передать\nзадание"
+tooltip: "Создать и передать задание между разделами"
+author: CPSK
diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png
new file mode 100644
index 0000000..3f9af65
Binary files /dev/null and b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png differ
diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/script.py b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/script.py
new file mode 100644
index 0000000..a4a9f2f
--- /dev/null
+++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/script.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+"""Открывает мастер передачи задания между разделами."""
+
+__title__ = "Передать\nзадание"
+__author__ = "CPSK"
+
+import os
+import sys
+
+
+SCRIPT_DIR = os.path.dirname(__file__)
+EXTENSION_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(SCRIPT_DIR))))
+LIB_DIR = os.path.join(EXTENSION_DIR, "lib")
+if LIB_DIR not in sys.path:
+ sys.path.insert(0, LIB_DIR)
+
+from gip_tasks import commands
+
+
+commands.run_create_task(__revit__.ActiveUIDocument)
diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml
new file mode 100644
index 0000000..5e3c6c8
--- /dev/null
+++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml
@@ -0,0 +1,3 @@
+title: "Журнал\nзаданий"
+tooltip: "Открыть журнал заданий между разделами"
+author: CPSK
diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png
new file mode 100644
index 0000000..63d6563
Binary files /dev/null and b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png differ
diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py
new file mode 100644
index 0000000..940645a
--- /dev/null
+++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+"""Открывает журнал заданий между разделами."""
+
+__title__ = "Журнал\nзаданий"
+__author__ = "CPSK"
+
+import os
+import sys
+
+
+SCRIPT_DIR = os.path.dirname(__file__)
+EXTENSION_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(SCRIPT_DIR))))
+LIB_DIR = os.path.join(EXTENSION_DIR, "lib")
+if LIB_DIR not in sys.path:
+ sys.path.insert(0, LIB_DIR)
+
+from gip_tasks import commands
+
+
+commands.run_journal(__revit__.ActiveUIDocument)
diff --git a/pyrevit.extension/lib/gip_tasks/__init__.py b/pyrevit.extension/lib/gip_tasks/__init__.py
new file mode 100644
index 0000000..9b7a1d8
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+"""GIP Tasks MVP package for pyRevit."""
+
+APP_NAME = "GIP Tasks"
+
diff --git a/pyrevit.extension/lib/gip_tasks/api_client.py b/pyrevit.extension/lib/gip_tasks/api_client.py
new file mode 100644
index 0000000..44aca7f
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/api_client.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+import json
+import sys
+try:
+ import urllib2
+ from urllib import urlencode
+except ImportError:
+ import urllib.request as urllib2
+ from urllib.parse import urlencode
+
+from . import config
+
+
+DEFAULT_TIMEOUT_SECONDS = 3
+
+
+class ApiError(Exception):
+ pass
+
+
+class ApiClient(object):
+ def __init__(self, settings=None):
+ self.settings = settings or config.load()
+ self.base_url = (self.settings.get("API_BASE_URL") or "").rstrip("/")
+ self.token = self.settings.get("AUTH_TOKEN") or ""
+ self.timeout = self._timeout()
+
+ def _timeout(self):
+ try:
+ return max(1, int(self.settings.get("API_TIMEOUT_SECONDS") or DEFAULT_TIMEOUT_SECONDS))
+ except Exception:
+ return DEFAULT_TIMEOUT_SECONDS
+
+ def request(self, method, path, payload=None, query=None):
+ if not self.base_url:
+ raise ApiError("API_BASE_URL is empty")
+ url = self.base_url + path
+ if query:
+ clean = {}
+ for key, value in query.items():
+ if value is not None and value != "":
+ clean[key] = value
+ if clean:
+ url += "?" + urlencode(clean)
+ body = None
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
+ if self.token:
+ headers["Authorization"] = "Bearer " + self.token
+ if payload is not None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ req = urllib2.Request(url, data=body, headers=headers)
+ req.get_method = lambda: method
+ try:
+ res = urllib2.urlopen(req, timeout=self.timeout)
+ raw = res.read()
+ if not raw:
+ return {}
+ if sys.version_info[0] < 3:
+ raw = raw.decode("utf-8")
+ return json.loads(raw)
+ except Exception as exc:
+ raise ApiError(str(exc))
+
+ def get_projects(self):
+ return self.request("GET", "/projects")
+
+ def create_project(self, payload):
+ return self.request("POST", "/projects", payload)
+
+ def list_tasks(self, project_id, filters=None):
+ if not project_id:
+ raise ApiError(u"Сначала выберите проект")
+ query = {"project_id": project_id}
+ query.update(filters or {})
+ return self.request("GET", "/tasks", query=query)
+
+ def create_task(self, payload):
+ if not payload.get("project_id"):
+ raise ApiError(u"Сначала выберите проект")
+ return self.request("POST", "/tasks", payload)
+
+ def patch_task(self, task_id, project_id, payload):
+ if not project_id:
+ raise ApiError(u"Сначала выберите проект")
+ payload = payload or {}
+ payload["project_id"] = project_id
+ return self.request("PATCH", "/tasks/%s" % task_id, payload)
+
+ def add_comment(self, task_id, project_id, comment):
+ if not project_id:
+ raise ApiError(u"Сначала выберите проект")
+ return self.request("POST", "/tasks/%s/comments" % task_id, {"project_id": project_id, "comment": comment})
+
+ def task_changes(self, project_id, since=None):
+ if not project_id:
+ raise ApiError(u"Сначала выберите проект")
+ return self.request("GET", "/tasks/changes", query={"project_id": project_id, "since": since})
diff --git a/pyrevit.extension/lib/gip_tasks/commands.py b/pyrevit.extension/lib/gip_tasks/commands.py
new file mode 100644
index 0000000..7278da7
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/commands.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+from pyrevit import forms
+
+
+def show_short_error(message):
+ forms.alert(message, title="GIP Tasks")
+
+
+def log_exception(message):
+ try:
+ from . import logger
+ logger.exception(message)
+ except Exception:
+ pass
+
+
+def run_create_task(uidoc):
+ try:
+ from . import config, revit_context, task_models, task_service
+ from . import marker_service
+ from .ui.create_task_window import CreateTaskWindow
+
+ doc = uidoc.Document
+ settings = config.load()
+ ctx = revit_context.collect(uidoc)
+ win = CreateTaskWindow(settings, ctx, uidoc)
+ if not win.show_dialog():
+ return None
+ marker = win.selected_marker
+ if not marker:
+ show_short_error(u"Сначала выберите маркер задания семейства CPSK_Маркер задания на активном виде.")
+ return None
+ payload = task_models.new_task_payload(config.load(), win.result, win.revit_context)
+ payload, status = task_service.create_task(payload)
+ marker_service.write_task_to_marker(doc, marker, payload)
+ forms.alert(status, title="GIP Tasks", warn_icon=False)
+ return payload
+ except Exception:
+ log_exception("Create task command failed")
+ show_short_error(u"Не удалось передать задание. Подробности записаны в лог.")
+ return None
+
+
+def run_journal(uidoc):
+ try:
+ from .ui.task_journal_window import TaskJournalWindow
+
+ win = TaskJournalWindow(uidoc)
+ win.show_dialog()
+ except Exception:
+ log_exception("Journal command failed")
+ show_short_error(u"Не удалось открыть журнал. Подробности записаны в лог.")
diff --git a/pyrevit.extension/lib/gip_tasks/config.py b/pyrevit.extension/lib/gip_tasks/config.py
new file mode 100644
index 0000000..b6cd968
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/config.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+import json
+import os
+
+
+DEFAULTS = {
+ "API_BASE_URL": "",
+ "AUTH_TOKEN": "",
+ "COMPANY_ID": "demo-company",
+ "CURRENT_PROJECT_ID": "",
+ "CURRENT_PROJECT_NAME": "",
+ "CURRENT_PROJECT_CODE": "",
+ "CURRENT_DISCIPLINE": "КЖ",
+ "CURRENT_USER": "",
+ "CURRENT_USER_ROLE": "project_coordinator",
+ "LOCAL_MODE": True,
+ "LOCAL_CACHE_PATH": "",
+ "API_TIMEOUT_SECONDS": 3,
+ "POLLING_INTERVAL_SECONDS": 30,
+ "MARKER_FAMILY_NAME": "CPSK_Маркер задания",
+ "MARKER_FAMILY_TYPE": "Новое",
+ "MARKER_FAMILY_PATH": r"C:\Users\saukouma\Documents\BIM библиотека\2-Семейства\Задания\CPSK_Маркер задания.rfa",
+}
+
+
+def _ensure_dir(path):
+ if path and not os.path.isdir(path):
+ os.makedirs(path)
+ return path
+
+
+def appdata_dir():
+ base = os.environ.get("APPDATA") or os.path.expanduser("~")
+ return _ensure_dir(os.path.join(base, "GIPTasks"))
+
+
+def extension_root():
+ return os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
+
+
+def config_path():
+ return os.path.join(appdata_dir(), "config.json")
+
+
+def _normalize(data):
+ data["MARKER_FAMILY_NAME"] = DEFAULTS["MARKER_FAMILY_NAME"]
+ data["MARKER_FAMILY_TYPE"] = DEFAULTS["MARKER_FAMILY_TYPE"]
+ if not data.get("CURRENT_DISCIPLINE"):
+ data["CURRENT_DISCIPLINE"] = DEFAULTS["CURRENT_DISCIPLINE"]
+ if not data.get("LOCAL_CACHE_PATH"):
+ data["LOCAL_CACHE_PATH"] = os.path.join(appdata_dir(), "gip_tasks_cache.sqlite")
+ data["LOCAL_MODE"] = not bool((data.get("API_BASE_URL") or "").strip())
+ return data
+
+
+def load():
+ data = DEFAULTS.copy()
+ path = config_path()
+ if os.path.exists(path):
+ try:
+ with open(path, "rb") as fp:
+ data.update(json.loads(fp.read().decode("utf-8")))
+ except Exception:
+ pass
+ return _normalize(data)
+
+
+def save(values):
+ data = DEFAULTS.copy()
+ data.update(values or {})
+ data = _normalize(data)
+ _ensure_dir(os.path.dirname(config_path()))
+ raw = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
+ with open(config_path(), "wb") as fp:
+ fp.write(raw.encode("utf-8"))
+ return data
+
+
+def set_current_project(project):
+ data = load()
+ data["CURRENT_PROJECT_ID"] = project.get("id") or project.get("project_id") or ""
+ data["CURRENT_PROJECT_NAME"] = project.get("name") or project.get("project_name") or ""
+ data["CURRENT_PROJECT_CODE"] = project.get("code") or project.get("project_code") or ""
+ if project.get("user_role"):
+ data["CURRENT_USER_ROLE"] = project.get("user_role")
+ return save(data)
+
+
+def can_create_project(settings=None):
+ role = (settings or load()).get("CURRENT_USER_ROLE") or ""
+ return role in ("admin", "project_coordinator", "lead_specialist")
+
+
+def is_local_mode(settings=None):
+ settings = settings or load()
+ return bool(settings.get("LOCAL_MODE")) or not bool((settings.get("API_BASE_URL") or "").strip())
diff --git a/pyrevit.extension/lib/gip_tasks/local_cache.py b/pyrevit.extension/lib/gip_tasks/local_cache.py
new file mode 100644
index 0000000..47c3498
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/local_cache.py
@@ -0,0 +1,265 @@
+# -*- coding: utf-8 -*-
+import json
+import os
+import uuid
+try:
+ import sqlite3
+except Exception:
+ sqlite3 = None
+
+from . import config, logger, task_models
+
+
+def connect(settings=None):
+ settings = settings or config.load()
+ if sqlite3 is None:
+ return None
+ con = sqlite3.connect(settings.get("LOCAL_CACHE_PATH"))
+ con.row_factory = sqlite3.Row
+ init(con)
+ return con
+
+
+def init(con):
+ con.execute("""create table if not exists projects (
+ id text primary key,
+ project_json text not null,
+ updated_at text not null
+ )""")
+ con.execute("""create table if not exists tasks (
+ id text primary key,
+ project_id text not null,
+ task_json text not null,
+ updated_at text not null
+ )""")
+ con.execute("""create table if not exists pending_queue (
+ id integer primary key autoincrement,
+ action text not null,
+ project_id text not null,
+ payload_json text not null,
+ created_at text not null,
+ attempts integer default 0,
+ last_error text
+ )""")
+ con.execute("create table if not exists sync_state (project_id text primary key, last_sync text)")
+ con.commit()
+
+
+def _json_path(name):
+ return os.path.join(config.appdata_dir(), name)
+
+
+def _read_json(path, fallback):
+ if not os.path.exists(path):
+ return fallback
+ try:
+ with open(path, "rb") as fp:
+ return json.loads(fp.read().decode("utf-8"))
+ except Exception:
+ logger.exception("Failed to read json cache: %s" % path)
+ return fallback
+
+
+def _write_json(path, data):
+ raw = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
+ with open(path, "wb") as fp:
+ fp.write(raw.encode("utf-8"))
+
+
+def local_projects_path():
+ return _json_path("local_projects.json")
+
+
+def list_local_projects():
+ con = connect()
+ if con is not None:
+ rows = con.execute("select project_json from projects order by updated_at desc").fetchall()
+ con.close()
+ return [json.loads(r["project_json"]) for r in rows]
+ return _read_json(local_projects_path(), {"items": []}).get("items") or []
+
+
+def save_local_project(payload, last_error=""):
+ project_id = payload.get("id") or payload.get("project_id") or "local-project-" + str(uuid.uuid4())
+ project = {
+ "id": project_id,
+ "company_id": payload.get("company_id") or "",
+ "name": payload.get("name") or payload.get("project_name") or u"Локальный проект",
+ "code": payload.get("code") or payload.get("project_code") or "",
+ "customer": payload.get("customer") or "",
+ "address": payload.get("address") or "",
+ "description": payload.get("description") or "",
+ "created_at": payload.get("created_at") or task_models.now_iso(),
+ "updated_at": task_models.now_iso(),
+ "user_role": payload.get("user_role") or "project_coordinator",
+ "is_local": True,
+ "is_active": bool(payload.get("is_active", True)),
+ "last_error": last_error or "",
+ }
+ cache_project(project)
+ return project
+
+
+def cache_project(project):
+ project_id = project.get("id") or project.get("project_id")
+ if not project_id:
+ return
+ project["updated_at"] = project.get("updated_at") or task_models.now_iso()
+ con = connect()
+ if con is not None:
+ con.execute(
+ "insert or replace into projects(id, project_json, updated_at) values(?,?,?)",
+ (project_id, json.dumps(project, ensure_ascii=False), project["updated_at"]),
+ )
+ con.commit()
+ con.close()
+ return
+ projects = [x for x in list_local_projects() if x.get("id") != project_id]
+ projects.append(project)
+ _write_json(local_projects_path(), {"items": projects})
+
+
+def cache_task(task):
+ task_id = task.get("id") or task.get("temporary_id")
+ project_id = task.get("project_id")
+ if not task_id or not project_id:
+ return
+ task["updated_at"] = task.get("updated_at") or task_models.now_iso()
+ con = connect()
+ if con is not None:
+ con.execute(
+ "insert or replace into tasks(id, project_id, task_json, updated_at) values(?,?,?,?)",
+ (task_id, project_id, json.dumps(task, ensure_ascii=False), task["updated_at"]),
+ )
+ con.commit()
+ con.close()
+ return
+ path = _json_path("local_tasks.json")
+ items = _read_json(path, {"items": []}).get("items") or []
+ items = [x for x in items if (x.get("id") or x.get("temporary_id")) != task_id]
+ items.append(task)
+ _write_json(path, {"items": items})
+
+
+def list_tasks(project_id):
+ if not project_id:
+ return []
+ con = connect()
+ if con is not None:
+ rows = con.execute("select task_json from tasks where project_id=? order by updated_at desc", (project_id,)).fetchall()
+ con.close()
+ return [json.loads(r["task_json"]) for r in rows]
+ items = _read_json(_json_path("local_tasks.json"), {"items": []}).get("items") or []
+ return [x for x in items if x.get("project_id") == project_id]
+
+
+def get_task(task_id, project_id=None):
+ for task in list_tasks(project_id) if project_id else _all_tasks():
+ if task.get("id") == task_id or task.get("temporary_id") == task_id:
+ return task
+ return None
+
+
+def _all_tasks():
+ con = connect()
+ if con is not None:
+ rows = con.execute("select task_json from tasks order by updated_at desc").fetchall()
+ con.close()
+ return [json.loads(r["task_json"]) for r in rows]
+ return _read_json(_json_path("local_tasks.json"), {"items": []}).get("items") or []
+
+
+def update_task(project_id, task_id, updates):
+ task = get_task(task_id, project_id)
+ if not task:
+ return None
+ task.update(updates or {})
+ task["updated_at"] = task_models.now_iso()
+ cache_task(task)
+ return task
+
+
+def add_comment(project_id, task_id, comment):
+ task = get_task(task_id, project_id)
+ if not task:
+ return None
+ comments = task.get("comments") or []
+ comments.append({"text": comment, "created_at": task_models.now_iso()})
+ task["comments"] = comments
+ if comment:
+ existing = task.get("comment") or ""
+ task["comment"] = (existing + "\n" + comment).strip() if existing else comment
+ task["updated_at"] = task_models.now_iso()
+ cache_task(task)
+ return task
+
+
+def enqueue(action, payload):
+ project_id = payload.get("project_id") or payload.get("id")
+ if not project_id:
+ return
+ con = connect()
+ if con is None:
+ return
+ con.execute(
+ "insert into pending_queue(action, project_id, payload_json, created_at) values(?,?,?,?)",
+ (action, project_id, json.dumps(payload, ensure_ascii=False), task_models.now_iso()),
+ )
+ con.commit()
+ con.close()
+
+
+def pending(project_id, limit=50):
+ con = connect()
+ if con is None:
+ return []
+ rows = con.execute("select * from pending_queue where project_id=? order by id limit ?", (project_id, limit)).fetchall()
+ con.close()
+ return [dict(r) for r in rows]
+
+
+def pending_count(project_id):
+ con = connect()
+ if con is None:
+ return 0
+ row = con.execute("select count(*) as c from pending_queue where project_id=?", (project_id,)).fetchone()
+ con.close()
+ return int(row["c"] or 0)
+
+
+def delete_pending(item_id):
+ con = connect()
+ if con is None:
+ return
+ con.execute("delete from pending_queue where id=?", (item_id,))
+ con.commit()
+ con.close()
+
+
+def mark_pending_error(item_id, error):
+ con = connect()
+ if con is None:
+ return
+ con.execute("update pending_queue set attempts=attempts+1, last_error=? where id=?", (error, item_id))
+ con.commit()
+ con.close()
+ logger.write("Pending queue error: %s" % error)
+
+
+def get_last_sync(project_id):
+ con = connect()
+ if con is None:
+ return ""
+ row = con.execute("select last_sync from sync_state where project_id=?", (project_id,)).fetchone()
+ con.close()
+ return row["last_sync"] if row else ""
+
+
+def set_last_sync(project_id, value):
+ con = connect()
+ if con is None:
+ return
+ con.execute("insert or replace into sync_state(project_id,last_sync) values(?,?)", (project_id, value))
+ con.commit()
+ con.close()
+
diff --git a/pyrevit.extension/lib/gip_tasks/logger.py b/pyrevit.extension/lib/gip_tasks/logger.py
new file mode 100644
index 0000000..5b45471
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/logger.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+import os
+import traceback
+from datetime import datetime
+
+from . import config
+
+
+def log_path():
+ return os.path.join(config.appdata_dir(), "gip_tasks.log")
+
+
+def write(message):
+ try:
+ line = "[%s] %s\n" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), message)
+ with open(log_path(), "ab") as fp:
+ fp.write(line.encode("utf-8"))
+ except Exception:
+ pass
+
+
+def exception(message):
+ write("%s\n%s" % (message, traceback.format_exc()))
+
diff --git a/pyrevit.extension/lib/gip_tasks/marker_service.py b/pyrevit.extension/lib/gip_tasks/marker_service.py
new file mode 100644
index 0000000..b32a7e1
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/marker_service.py
@@ -0,0 +1,185 @@
+# -*- coding: utf-8 -*-
+try:
+ unicode
+except NameError:
+ unicode = str
+
+from datetime import datetime
+
+try:
+ from Autodesk.Revit.DB import BuiltInCategory, ElementId, StorageType, Transaction
+ from Autodesk.Revit.Exceptions import OperationCanceledException
+ from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType
+except Exception:
+ BuiltInCategory = ElementId = StorageType = Transaction = None
+ OperationCanceledException = Exception
+ ISelectionFilter = object
+ ObjectType = None
+
+from . import config, logger, task_models
+
+
+MARKER_FAMILY_NAME = "CPSK_Маркер задания"
+TEXT_LIMIT = 950
+WRITABLE_MARKER_PARAMS = [
+ u"CPSK_Дата",
+ u"CPSK_Статус",
+ u"CPSK_Пометка",
+ u"CPSK_Комментарии",
+ u"CPSK_Дисциплина",
+ u"Новое",
+ u"Принято",
+ u"Отменено",
+]
+
+
+class CpskMarkerSelectionFilter(ISelectionFilter):
+ def AllowElement(self, element):
+ return is_cpsk_marker(element)
+
+ def AllowReference(self, reference, position):
+ return False
+
+
+def is_cpsk_marker(element):
+ try:
+ if not element or not element.Category:
+ return False
+ if element.Category.Id.IntegerValue != int(BuiltInCategory.OST_DetailComponents):
+ return False
+ return marker_family_name(element) == MARKER_FAMILY_NAME
+ except Exception:
+ return False
+
+
+def marker_family_name(element):
+ try:
+ return element.Symbol.Family.Name
+ except Exception:
+ return ""
+
+
+def pick_marker(uidoc):
+ message = u"Выберите на активном виде маркер задания.\nКатегория: Элементы узлов.\nСемейство: CPSK_Маркер задания."
+ try:
+ ref = uidoc.Selection.PickObject(ObjectType.Element, CpskMarkerSelectionFilter(), message)
+ marker = uidoc.Document.GetElement(ref.ElementId)
+ if not is_cpsk_marker(marker):
+ return None, u"Выбран не маркер CPSK_Маркер задания"
+ return marker, ""
+ except OperationCanceledException:
+ return None, u""
+ except Exception:
+ logger.exception("Marker selection failed")
+ return None, u"Маркер не выбран"
+
+
+def marker_datetime_text():
+ return datetime.now().strftime("%d.%m.%Y %H:%M")
+
+
+def set_marker_parameter_safe(element, param_name, value):
+ try:
+ param = element.LookupParameter(param_name)
+ if not param:
+ logger.write("Marker parameter not found: %s" % param_name)
+ return False
+ if param.IsReadOnly:
+ logger.write("Marker parameter is read-only: %s" % param_name)
+ return False
+ storage = param.StorageType
+ if storage == StorageType.Integer:
+ param.Set(1 if value in (True, 1, "1", "true", "True", u"Да") else 0)
+ elif storage == StorageType.Double:
+ logger.write("Skip numeric marker parameter: %s" % param_name)
+ return False
+ else:
+ param.Set("" if value is None else unicode(value))
+ return True
+ except Exception:
+ logger.exception("Failed to set marker parameter: %s" % param_name)
+ return False
+
+
+def marker_parameter_text(element, param_name):
+ try:
+ param = element.LookupParameter(param_name)
+ if not param:
+ return u""
+ if param.StorageType == StorageType.Integer:
+ return unicode(param.AsInteger())
+ if param.StorageType == StorageType.Double:
+ try:
+ return unicode(param.AsValueString() or "")
+ except Exception:
+ return unicode(param.AsDouble())
+ try:
+ return unicode(param.AsString() or param.AsValueString() or "")
+ except Exception:
+ return unicode(param.AsValueString() or "")
+ except Exception:
+ return u""
+
+
+def marker_parameter_values(element):
+ values = {}
+ for param_name in WRITABLE_MARKER_PARAMS:
+ value = marker_parameter_text(element, param_name)
+ if value not in (None, u""):
+ values[param_name] = value
+ return values
+
+
+def marker_status(status):
+ return task_models.server_to_marker_status(status)
+
+
+def apply_marker_status(element, status):
+ status = marker_status(status)
+ set_marker_parameter_safe(element, u"CPSK_Статус", status)
+ set_marker_parameter_safe(element, u"Новое", status == u"Новое")
+ set_marker_parameter_safe(element, u"Принято", status == u"Принято")
+ set_marker_parameter_safe(element, u"Отменено", status == u"Отменено")
+
+
+def _comment_text(task):
+ parts = []
+ if task.get("description"):
+ parts.append(task.get("description"))
+ if task.get("comment"):
+ parts.append(task.get("comment"))
+ text = "\n".join(parts)
+ if len(text) > TEXT_LIMIT:
+ logger.write("Marker comments truncated for task %s" % (task.get("id") or task.get("temporary_id") or ""))
+ return text[:TEXT_LIMIT - 1]
+ return text
+
+
+def write_task_to_marker(doc, marker, task, update_date=True):
+ if not marker:
+ raise ValueError(u"Маркер не выбран")
+ if not is_cpsk_marker(marker):
+ raise ValueError(u"Выбран не маркер CPSK_Маркер задания")
+ tx = Transaction(doc, "Записать задание в маркер")
+ tx.Start()
+ try:
+ apply_marker_status(marker, task.get("marker_status") or task.get("status") or u"Новое")
+ if update_date:
+ set_marker_parameter_safe(marker, u"CPSK_Дата", marker_datetime_text())
+ set_marker_parameter_safe(marker, u"CPSK_Пометка", task.get("type") or u"прочее")
+ set_marker_parameter_safe(marker, u"CPSK_Комментарии", _comment_text(task))
+ set_marker_parameter_safe(marker, u"CPSK_Дисциплина", u"%s → %s" % (task.get("sender_discipline") or "", task.get("receiver_discipline") or ""))
+ tx.Commit()
+ except Exception:
+ tx.RollBack()
+ logger.exception("Failed to write task to marker")
+ raise
+
+
+def find_marker_by_unique_id(doc, unique_id):
+ if not unique_id:
+ return None
+ try:
+ return doc.GetElement(unique_id)
+ except Exception:
+ return None
diff --git a/pyrevit.extension/lib/gip_tasks/project_service.py b/pyrevit.extension/lib/gip_tasks/project_service.py
new file mode 100644
index 0000000..7d79662
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/project_service.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+try:
+ basestring
+except NameError:
+ basestring = str
+
+import json
+
+from . import api_client, config, local_cache, logger
+
+
+def normalize_items(data):
+ if isinstance(data, dict) and "items" in data:
+ return data.get("items") or []
+ if isinstance(data, list):
+ return data
+ return []
+
+
+def merge_projects(server_projects, local_projects):
+ result = []
+ seen = set()
+ for project in (server_projects or []) + (local_projects or []):
+ pid = project.get("id") or project.get("project_id")
+ if pid and pid not in seen:
+ seen.add(pid)
+ result.append(project)
+ return result
+
+
+def current_project_from_settings(settings):
+ project_id = settings.get("CURRENT_PROJECT_ID") or ""
+ if not project_id:
+ return None
+ return {
+ "id": project_id,
+ "name": settings.get("CURRENT_PROJECT_NAME") or project_id,
+ "code": settings.get("CURRENT_PROJECT_CODE") or "",
+ "user_role": settings.get("CURRENT_USER_ROLE") or "",
+ "is_local": config.is_local_mode(settings),
+ }
+
+
+def cached_projects(settings=None):
+ settings = settings or config.load()
+ projects = local_cache.list_local_projects()
+ current = current_project_from_settings(settings)
+ if current:
+ projects = merge_projects(projects, [current])
+ return projects
+
+
+def load_projects(use_server=True):
+ settings = config.load()
+ local_projects = cached_projects(settings)
+ if not use_server:
+ if config.is_local_mode(settings):
+ return local_projects, u"Локальный режим"
+ return local_projects, u"Показан локальный кэш. Нажмите «Обновить» для синхронизации"
+ if config.is_local_mode(settings):
+ return local_projects, u"Сервер не настроен, используется локальный режим"
+ try:
+ projects = normalize_items(api_client.ApiClient(settings).get_projects())
+ for project in projects:
+ local_cache.cache_project(project)
+ return merge_projects(projects, local_projects), u""
+ except Exception as exc:
+ logger.exception("Failed to load projects")
+ return local_projects, u"Сервер недоступен, используется локальный режим"
+
+
+def choose_project(project):
+ if not project:
+ return config.load()
+ return config.set_current_project(project)
+
+
+def create_project(payload):
+ settings = config.load()
+ if config.is_local_mode(settings):
+ result = local_cache.save_local_project(payload)
+ config.set_current_project(result)
+ return result, u"Проект создан локально"
+ try:
+ result = api_client.ApiClient(settings).create_project(payload)
+ if isinstance(result, dict):
+ local_cache.cache_project(result)
+ config.set_current_project(result)
+ return result, u"Проект создан"
+ except Exception as exc:
+ logger.exception("Failed to create project via API")
+ result = local_cache.save_local_project(payload, str(exc))
+ config.set_current_project(result)
+ return result, u"Сервер недоступен, проект создан локально"
+
+
+def require_project(settings=None):
+ settings = settings or config.load()
+ if not settings.get("CURRENT_PROJECT_ID"):
+ raise ValueError(u"Сначала выберите проект")
+ return settings
+
+
+def sync_pending(project_id):
+ settings = config.load()
+ if config.is_local_mode(settings):
+ return 0
+ client = api_client.ApiClient(settings)
+ sent = 0
+ for item in local_cache.pending(project_id):
+ try:
+ payload = item.get("payload_json")
+ payload = json.loads(payload) if isinstance(payload, basestring) else payload
+ if item.get("action") == "create_task":
+ res = client.create_task(payload)
+ if isinstance(res, dict):
+ local_cache.cache_task(res)
+ elif item.get("action") == "patch_task":
+ client.patch_task(payload.get("id") or payload.get("temporary_id"), payload.get("project_id"), payload)
+ elif item.get("action") == "comment_task":
+ client.add_comment(payload.get("task_id"), payload.get("project_id"), payload.get("comment"))
+ local_cache.delete_pending(item.get("id"))
+ sent += 1
+ except Exception as exc:
+ local_cache.mark_pending_error(item.get("id"), str(exc))
+ return sent
diff --git a/pyrevit.extension/lib/gip_tasks/revit_context.py b/pyrevit.extension/lib/gip_tasks/revit_context.py
new file mode 100644
index 0000000..627f4cc
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/revit_context.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+try:
+ from Autodesk.Revit.DB import LocationPoint
+except Exception:
+ LocationPoint = None
+
+MM_PER_FOOT = 304.8
+
+
+def xyz_to_dict(point):
+ if point is None:
+ return {}
+ return {"x": point.X * MM_PER_FOOT, "y": point.Y * MM_PER_FOOT, "z": point.Z * MM_PER_FOOT}
+
+
+def active_view_info(doc):
+ view = doc.ActiveView
+ level = ""
+ try:
+ if view.GenLevel:
+ level = view.GenLevel.Name
+ except Exception:
+ pass
+ return {"id": view.Id.IntegerValue, "name": view.Name, "view_type": str(view.ViewType), "level": level}
+
+
+def marker_info(marker):
+ family_name = ""
+ type_name = ""
+ try:
+ family_name = marker.Symbol.Family.Name
+ type_name = marker.Symbol.Name
+ except Exception:
+ pass
+ location = {}
+ try:
+ if marker.Location:
+ location = xyz_to_dict(marker.Location.Point)
+ except Exception:
+ pass
+ return {
+ "element_id": marker.Id.IntegerValue,
+ "unique_id": getattr(marker, "UniqueId", ""),
+ "family_name": family_name,
+ "type_name": type_name,
+ "location": location,
+ }
+
+
+def collect(uidoc, marker=None):
+ doc = uidoc.Document
+ data = {
+ "document_title": doc.Title,
+ "document_path": getattr(doc, "PathName", ""),
+ "revit_username": doc.Application.Username,
+ "active_view": active_view_info(doc),
+ }
+ if marker is not None:
+ data["marker"] = marker_info(marker)
+ return data
+
diff --git a/pyrevit.extension/lib/gip_tasks/sync_service.py b/pyrevit.extension/lib/gip_tasks/sync_service.py
new file mode 100644
index 0000000..08202b9
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/sync_service.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from . import api_client, local_cache, task_models
+
+
+def poll_project(project_id):
+ if not project_id:
+ raise ValueError(u"Сначала выберите проект")
+ client = api_client.ApiClient()
+ since = local_cache.get_last_sync(project_id)
+ data = client.task_changes(project_id, since)
+ items = data.get("items") if isinstance(data, dict) else data
+ count = 0
+ for task in items or []:
+ local_cache.cache_task(task)
+ count += 1
+ local_cache.set_last_sync(project_id, task_models.now_iso())
+ return count
+
diff --git a/pyrevit.extension/lib/gip_tasks/task_models.py b/pyrevit.extension/lib/gip_tasks/task_models.py
new file mode 100644
index 0000000..fb1dde5
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/task_models.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+import uuid
+from datetime import datetime
+
+
+TASK_TYPES = [u"отверстие", u"проём", u"закладная", u"фундамент", u"площадка", u"коллизия", u"уточнение", u"замечание", u"прочее"]
+MARKER_STATUSES = [u"Новое", u"Принято", u"Отменено"]
+PRIORITIES = [u"низкий", u"обычный", u"высокий", u"срочный"]
+DISCIPLINES = [u"КЖ", u"КМ", u"АР", u"ОВ", u"ВК", u"ЭОМ", u"СС", u"ТХ"]
+
+
+def now_iso():
+ return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
+
+
+def now_local_text():
+ return datetime.now().strftime("%d.%m.%Y %H:%M")
+
+
+def server_to_marker_status(status):
+ if status in ("cancelled", "returned", u"Отменено"):
+ return u"Отменено"
+ if status in ("in_progress", "review", "closed", u"Принято"):
+ return u"Принято"
+ return u"Новое"
+
+
+def marker_to_server_status(status):
+ if status == u"Принято":
+ return "in_progress"
+ if status == u"Отменено":
+ return "cancelled"
+ return "new"
+
+
+def new_project_payload(settings, form_data):
+ return {
+ "id": form_data.get("id") or "",
+ "company_id": settings.get("COMPANY_ID"),
+ "name": form_data.get("name") or "",
+ "code": form_data.get("code") or "",
+ "customer": form_data.get("customer") or "",
+ "address": form_data.get("address") or "",
+ "description": form_data.get("description") or "",
+ "created_at": form_data.get("created_at") or now_iso(),
+ "updated_at": now_iso(),
+ "is_local": bool(form_data.get("is_local", False)),
+ "is_active": bool(form_data.get("is_active", True)),
+ "is_archived": False,
+ }
+
+
+def new_task_payload(settings, form_data, revit_context):
+ project_id = settings.get("CURRENT_PROJECT_ID") or form_data.get("project_id") or ""
+ if not project_id:
+ raise ValueError(u"Сначала выберите проект")
+ marker = (revit_context or {}).get("marker") or {}
+ view = (revit_context or {}).get("active_view") or {}
+ title = form_data.get("type") or u"Задание"
+ marker_status = form_data.get("marker_status") or u"Новое"
+ payload = {
+ "id": form_data.get("id") or "",
+ "company_id": settings.get("COMPANY_ID"),
+ "project_id": project_id,
+ "project_name": settings.get("CURRENT_PROJECT_NAME"),
+ "project_code": settings.get("CURRENT_PROJECT_CODE"),
+ "title": title,
+ "description": form_data.get("description") or "",
+ "comment": form_data.get("comment") or "",
+ "type": form_data.get("type") or u"прочее",
+ "status": marker_to_server_status(marker_status),
+ "marker_status": marker_status,
+ "priority": form_data.get("priority") or u"обычный",
+ "sender_user_id": settings.get("CURRENT_USER"),
+ "sender_discipline": form_data.get("sender_discipline") or settings.get("CURRENT_DISCIPLINE"),
+ "receiver_discipline": form_data.get("receiver_discipline") or "",
+ "due_date": form_data.get("due_date") or "",
+ "created_at": form_data.get("created_at") or now_iso(),
+ "updated_at": now_iso(),
+ "source_model_name": (revit_context or {}).get("document_title") or "",
+ "current_revit_file_path": (revit_context or {}).get("document_path") or "",
+ "marker_element_id": marker.get("element_id") or "",
+ "marker_unique_id": marker.get("unique_id") or "",
+ "marker_family_name": marker.get("family_name") or "",
+ "marker_coordinates": marker.get("location") or {},
+ "level_name": view.get("level") or "",
+ "view_name": view.get("name") or "",
+ "view_id": view.get("id") or "",
+ "is_deleted": False,
+ }
+ if not payload["id"]:
+ payload["temporary_id"] = "local-task-" + str(uuid.uuid4())
+ return payload
+
diff --git a/pyrevit.extension/lib/gip_tasks/task_service.py b/pyrevit.extension/lib/gip_tasks/task_service.py
new file mode 100644
index 0000000..5f93715
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/task_service.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+from . import api_client, config, local_cache, logger, task_models
+
+
+def list_tasks(project_id, filters=None, use_server=True):
+ if not project_id:
+ raise ValueError(u"Сначала выберите проект")
+ settings = config.load()
+ if not use_server:
+ if config.is_local_mode(settings):
+ return local_cache.list_tasks(project_id), u"Локальный режим"
+ return local_cache.list_tasks(project_id), u"Показан локальный кэш. Нажмите «Обновить» для синхронизации"
+ if config.is_local_mode(settings):
+ return local_cache.list_tasks(project_id), u"Сервер не настроен, используется локальный режим"
+ try:
+ data = api_client.ApiClient(settings).list_tasks(project_id, filters or {})
+ items = data.get("items") if isinstance(data, dict) else data
+ items = items or []
+ for task in items:
+ local_cache.cache_task(task)
+ return items, u""
+ except Exception:
+ logger.exception("Failed to load tasks")
+ return local_cache.list_tasks(project_id), u"Сервер недоступен, показан локальный кэш"
+
+
+def create_task(payload):
+ settings = config.load()
+ if not payload.get("project_id"):
+ raise ValueError(u"Сначала выберите проект")
+ if config.is_local_mode(settings):
+ local_cache.cache_task(payload)
+ return payload, u"Задание сохранено локально"
+ try:
+ result = api_client.ApiClient(settings).create_task(payload)
+ if isinstance(result, dict):
+ payload.update(result)
+ local_cache.cache_task(payload)
+ return payload, u"Задание отправлено"
+ except Exception:
+ logger.exception("Failed to create task via API")
+ local_cache.cache_task(payload)
+ local_cache.enqueue("create_task", payload)
+ return payload, u"Сервер недоступен, задание сохранено локально"
+
+
+def update_status(task, marker_status):
+ project_id = task.get("project_id")
+ task_id = task.get("id") or task.get("temporary_id")
+ updates = {
+ "marker_status": marker_status,
+ "status": task_models.marker_to_server_status(marker_status),
+ "updated_at": task_models.now_iso(),
+ }
+ settings = config.load()
+ if config.is_local_mode(settings) or not task.get("id"):
+ updated = local_cache.update_task(project_id, task_id, updates)
+ return updated or task, u"Статус сохранен локально"
+ try:
+ updated = api_client.ApiClient(settings).patch_task(task.get("id"), project_id, updates)
+ if isinstance(updated, dict):
+ local_cache.cache_task(updated)
+ return updated, u"Статус обновлен"
+ except Exception:
+ logger.exception("Failed to update status via API")
+ local_cache.enqueue("patch_task", dict(task, **updates))
+ updated = local_cache.update_task(project_id, task_id, updates)
+ return updated or task, u"Сервер недоступен, статус сохранен локально"
+
+
+def add_comment(task, comment):
+ project_id = task.get("project_id")
+ task_id = task.get("id") or task.get("temporary_id")
+ settings = config.load()
+ if config.is_local_mode(settings) or not task.get("id"):
+ updated = local_cache.add_comment(project_id, task_id, comment)
+ return updated or task, u"Комментарий сохранен локально"
+ try:
+ api_client.ApiClient(settings).add_comment(task.get("id"), project_id, comment)
+ except Exception:
+ logger.exception("Failed to send comment via API")
+ local_cache.enqueue("comment_task", {"project_id": project_id, "task_id": task.get("id"), "comment": comment})
+ updated = local_cache.add_comment(project_id, task_id, comment)
+ return updated or task, u"Комментарий сохранен"
diff --git a/pyrevit.extension/lib/gip_tasks/ui/__init__.py b/pyrevit.extension/lib/gip_tasks/ui/__init__.py
new file mode 100644
index 0000000..633f866
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+
diff --git a/pyrevit.extension/lib/gip_tasks/ui/create_project_window.py b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.py
new file mode 100644
index 0000000..ddd2da0
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+import os
+from pyrevit import forms
+from gip_tasks import config
+from gip_tasks.ui import theme
+
+
+def xaml_path(name):
+ return os.path.join(config.extension_root(), "lib", "gip_tasks", "ui", name)
+
+
+class CreateProjectWindow(forms.WPFWindow):
+ def __init__(self):
+ forms.WPFWindow.__init__(self, xaml_path("create_project_window.xaml"))
+ self.result = None
+ theme.apply_theme(self)
+
+ def create_click(self, sender, args):
+ name = (self.NameBox.Text or "").strip()
+ if not name:
+ forms.alert(u"Укажите название проекта", title="GIP Tasks")
+ return
+ self.result = {
+ "name": name,
+ "is_active": True,
+ }
+ self.DialogResult = True
+ self.Close()
+
+ def cancel_click(self, sender, args):
+ self.DialogResult = False
+ self.Close()
diff --git a/pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml
new file mode 100644
index 0000000..c87ebfa
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyrevit.extension/lib/gip_tasks/ui/create_task_window.py b/pyrevit.extension/lib/gip_tasks/ui/create_task_window.py
new file mode 100644
index 0000000..c02b434
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/create_task_window.py
@@ -0,0 +1,304 @@
+# -*- coding: utf-8 -*-
+import os
+try:
+ unicode
+except NameError:
+ unicode = str
+
+try:
+ from System import DateTime
+except Exception:
+ DateTime = None
+
+from System.Windows import Visibility
+from pyrevit import forms
+from gip_tasks import config, marker_service, project_service, revit_context, task_models
+from gip_tasks.ui import theme
+from gip_tasks.ui.create_project_window import CreateProjectWindow
+from gip_tasks.ui.select_project_window import ProjectItem
+
+
+def xaml_path(name):
+ return os.path.join(config.extension_root(), "lib", "gip_tasks", "ui", name)
+
+
+def _visible(value):
+ return Visibility.Visible if value else Visibility.Collapsed
+
+
+class CreateTaskWindow(forms.WPFWindow):
+ def __init__(self, settings, revit_context_data, uidoc=None):
+ forms.WPFWindow.__init__(self, xaml_path("create_task_window.xaml"))
+ self.settings = settings
+ self.uidoc = uidoc
+ self.revit_context = revit_context_data or {}
+ self.selected_marker = None
+ self.result = None
+ self.projects = []
+ self._bind()
+ theme.apply_theme(self)
+ self._update_marker_card()
+ self._set_task_fields_enabled(bool((self.revit_context.get("marker") or {}).get("element_id")))
+ self.update_send_state()
+
+ def _bind(self):
+ self.CreateProjectButton.Visibility = _visible(config.can_create_project(self.settings))
+ self.SenderDisciplineBox.ItemsSource = task_models.DISCIPLINES
+ self.ReceiverDisciplineBox.ItemsSource = task_models.DISCIPLINES
+ self.TypeBox.ItemsSource = task_models.TASK_TYPES
+ self.PriorityBox.ItemsSource = task_models.PRIORITIES
+ self.SenderDisciplineBox.SelectedItem = self.settings.get("CURRENT_DISCIPLINE") or u"КЖ"
+ self.ReceiverDisciplineBox.SelectedIndex = 1 if len(task_models.DISCIPLINES) > 1 else 0
+ self.TypeBox.SelectedItem = u"прочее"
+ self.PriorityBox.SelectedItem = u"обычный"
+ self.refresh_projects(silent=True, use_server=False)
+ if (self.revit_context.get("marker") or {}).get("element_id"):
+ self._set_marker_context(None, self.revit_context)
+
+ def _mode_text(self, status):
+ status = status or u""
+ lowered = status.lower()
+ if config.is_local_mode(self.settings):
+ return u"Локальный режим"
+ if u"недоступен" in lowered:
+ return u"Сервер недоступен"
+ if u"кэш" in lowered:
+ return u"Локальный кэш"
+ if u"локальный режим" in lowered:
+ return u"Локальный режим"
+ return u"Сервер подключен"
+
+ def _select_project_in_combo(self):
+ current_id = self.settings.get("CURRENT_PROJECT_ID")
+ for item in self.ProjectCombo.ItemsSource or []:
+ if item.data.get("id") == current_id:
+ self.ProjectCombo.SelectedItem = item
+ break
+
+ def refresh_projects(self, silent=False, use_server=True):
+ projects, status = project_service.load_projects(use_server=use_server)
+ self.projects = projects
+ self.ProjectCombo.ItemsSource = [ProjectItem(x) for x in projects]
+ self._select_project_in_combo()
+ mode_text = self._mode_text(status)
+ self.ConnectionStatusText.Text = mode_text
+ self.ModeBadge.Text = mode_text
+ self.StatusText.Text = status or u"Готово"
+ theme.set_connection_state(self.ConnectionDot, mode_text)
+ self.update_send_state()
+
+ def project_changed(self, sender, args):
+ item = self.ProjectCombo.SelectedItem
+ if item:
+ self.settings = project_service.choose_project(item.data)
+ self.update_send_state()
+
+ def refresh_projects_click(self, sender, args):
+ self.refresh_projects(use_server=True)
+
+ def create_project_click(self, sender, args):
+ win = CreateProjectWindow()
+ if win.show_dialog():
+ payload = task_models.new_project_payload(config.load(), win.result)
+ project, status = project_service.create_project(payload)
+ self.settings = project_service.choose_project(project)
+ self.refresh_projects(silent=True, use_server=False)
+ self.StatusText.Text = status
+
+ def _set_marker_context(self, marker, context):
+ self.selected_marker = marker
+ self.revit_context = context or self.revit_context or {}
+ self._update_marker_card()
+ self._set_task_fields_enabled(bool((self.revit_context.get("marker") or {}).get("element_id")))
+ self.update_send_state()
+
+ def pick_marker_click(self, sender, args):
+ if not self.uidoc:
+ forms.alert(u"Активный документ Revit недоступен", title="GIP Tasks")
+ return
+ marker = None
+ message = u""
+ try:
+ try:
+ self.Hide()
+ except Exception:
+ pass
+ marker, message = marker_service.pick_marker(self.uidoc)
+ finally:
+ try:
+ self.Show()
+ self.Activate()
+ except Exception:
+ pass
+ if message:
+ forms.alert(u"Выберите маркер задания семейства CPSK_Маркер задания на активном виде.", title="GIP Tasks")
+ self.StatusText.Text = message
+ return
+ if not marker:
+ return
+ context = revit_context.collect(self.uidoc, marker)
+ params = marker_service.marker_parameter_values(marker)
+ context.setdefault("marker", {})["parameters"] = params
+ self._set_marker_context(marker, context)
+ self.StatusText.Text = u"Маркер выбран"
+
+ def _marker_value(self, key, fallback=u"не задано"):
+ marker = self.revit_context.get("marker") or {}
+ value = marker.get(key)
+ return unicode(value) if value not in (None, "") else fallback
+
+ def _update_marker_card(self):
+ marker = self.revit_context.get("marker") or {}
+ active_view = self.revit_context.get("active_view") or {}
+ has_marker = bool(marker.get("element_id"))
+ self.MarkerSelectedPanel.Visibility = _visible(has_marker)
+ self.MarkerDetailsPanel.Visibility = _visible(has_marker)
+ self.MarkerHintText.Visibility = _visible(not has_marker)
+ self.MarkerNameText.Text = marker.get("family_name") or u"CPSK_Маркер задания"
+ self.MarkerTypeText.Text = marker.get("type_name") or u"не задано"
+ self.MarkerViewText.Text = active_view.get("name") or u"не задано"
+ self.MarkerLevelText.Text = active_view.get("level") or u"не задано"
+ self.MarkerIdText.Text = unicode(marker.get("element_id") or u"не задано")
+ params = marker.get("parameters") or {}
+ lines = []
+ for key in sorted(params.keys()):
+ value = params.get(key) or u"не задано"
+ lines.append(u"%s: %s" % (key, value))
+ self.MarkerParamsText.Text = u"\n".join(lines) if lines else u"Параметры маркера не заполнены"
+
+ def _task_field_controls(self):
+ return [
+ self.SenderDisciplineBox,
+ self.ReceiverDisciplineBox,
+ self.TypeBox,
+ self.PriorityBox,
+ self.DueDatePicker,
+ self.TodayButton,
+ self.TomorrowButton,
+ self.ThreeDaysButton,
+ self.SevenDaysButton,
+ self.DescriptionBox,
+ self.CommentBox,
+ ]
+
+ def _set_task_fields_enabled(self, enabled):
+ for control in self._task_field_controls():
+ try:
+ control.IsEnabled = enabled
+ except Exception:
+ pass
+
+ def _due_date_text(self):
+ selected = self.DueDatePicker.SelectedDate
+ if selected is None:
+ return u""
+ try:
+ selected = selected.Value
+ except Exception:
+ pass
+ try:
+ return selected.ToString("dd.MM.yyyy")
+ except Exception:
+ return unicode(selected)
+
+ def set_due_date(self, days):
+ if DateTime is None:
+ return
+ self.DueDatePicker.SelectedDate = DateTime.Today.AddDays(days)
+ self.update_send_state()
+
+ def today_click(self, sender, args):
+ self.set_due_date(0)
+
+ def tomorrow_click(self, sender, args):
+ self.set_due_date(1)
+
+ def three_days_click(self, sender, args):
+ self.set_due_date(3)
+
+ def seven_days_click(self, sender, args):
+ self.set_due_date(7)
+
+ def field_changed(self, sender=None, args=None):
+ self._update_placeholders()
+ self.update_send_state()
+
+ def _update_placeholders(self):
+ try:
+ self.DescriptionPlaceholder.Visibility = _visible(not bool((self.DescriptionBox.Text or "").strip()))
+ self.CommentPlaceholder.Visibility = _visible(not bool((self.CommentBox.Text or "").strip()))
+ except Exception:
+ pass
+
+ def _update_due_chips(self):
+ selected = self._due_date_text()
+ buttons = [
+ (self.TodayButton, 0),
+ (self.TomorrowButton, 1),
+ (self.ThreeDaysButton, 3),
+ (self.SevenDaysButton, 7),
+ ]
+ for button, days in buttons:
+ active = False
+ if DateTime is not None and selected:
+ try:
+ active = DateTime.Today.AddDays(days).ToString("dd.MM.yyyy") == selected
+ except Exception:
+ active = False
+ theme.set_chip_state(button, active)
+
+ def _validation_errors(self):
+ errors = []
+ if not self.settings.get("CURRENT_PROJECT_ID"):
+ errors.append(u"выберите проект")
+ if not bool((self.revit_context.get("marker") or {}).get("element_id")):
+ errors.append(u"выберите маркер задания")
+ if not self.SenderDisciplineBox.SelectedItem:
+ errors.append(u"укажите раздел «От кого»")
+ if not self.ReceiverDisciplineBox.SelectedItem:
+ errors.append(u"укажите раздел «Кому»")
+ if not self.TypeBox.SelectedItem:
+ errors.append(u"выберите тип")
+ if not self.PriorityBox.SelectedItem:
+ errors.append(u"выберите приоритет")
+ if not self._due_date_text():
+ errors.append(u"выберите срок")
+ if not (self.DescriptionBox.Text or "").strip():
+ errors.append(u"заполните описание")
+ return errors
+
+ def update_send_state(self):
+ try:
+ errors = self._validation_errors()
+ self.SendButton.IsEnabled = len(errors) == 0
+ self.ValidationText.Text = u"Готово к передаче" if not errors else u"Для передачи заполните: " + u", ".join(errors)
+ self._update_placeholders()
+ self._update_due_chips()
+ except Exception:
+ pass
+
+ def send_click(self, sender, args):
+ item = self.ProjectCombo.SelectedItem
+ if item:
+ self.settings = project_service.choose_project(item.data)
+ errors = self._validation_errors()
+ if errors:
+ self.ValidationText.Text = u"Заполните обязательные поля: " + u", ".join(errors)
+ forms.alert(self.ValidationText.Text, title="GIP Tasks")
+ return
+ self.result = {
+ "sender_discipline": self.SenderDisciplineBox.SelectedItem,
+ "receiver_discipline": self.ReceiverDisciplineBox.SelectedItem,
+ "type": self.TypeBox.SelectedItem,
+ "priority": self.PriorityBox.SelectedItem,
+ "due_date": self._due_date_text(),
+ "description": (self.DescriptionBox.Text or "").strip(),
+ "comment": (self.CommentBox.Text or "").strip(),
+ "marker_status": u"Новое",
+ }
+ self.DialogResult = True
+ self.Close()
+
+ def cancel_click(self, sender, args):
+ self.DialogResult = False
+ self.Close()
diff --git a/pyrevit.extension/lib/gip_tasks/ui/create_task_window.xaml b/pyrevit.extension/lib/gip_tasks/ui/create_task_window.xaml
new file mode 100644
index 0000000..4d3f5c9
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/create_task_window.xaml
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyrevit.extension/lib/gip_tasks/ui/select_project_window.py b/pyrevit.extension/lib/gip_tasks/ui/select_project_window.py
new file mode 100644
index 0000000..74f0bca
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/select_project_window.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+import os
+from System.Windows import Visibility
+from pyrevit import forms
+from gip_tasks import config, project_service, task_models
+from gip_tasks.ui import theme
+from gip_tasks.ui.create_project_window import CreateProjectWindow
+
+
+def xaml_path(name):
+ return os.path.join(config.extension_root(), "lib", "gip_tasks", "ui", name)
+
+
+def project_display(project):
+ code = project.get("code") or project.get("project_code") or ""
+ name = project.get("name") or project.get("project_name") or project.get("id") or ""
+ label = ("%s - %s" % (code, name)).strip(" -")
+ if project.get("is_local"):
+ label += u" (локальный проект)"
+ return label
+
+
+class ProjectItem(object):
+ def __init__(self, data):
+ self.data = data
+ self.display_name = project_display(data)
+
+
+class SelectProjectWindow(forms.WPFWindow):
+ def __init__(self):
+ forms.WPFWindow.__init__(self, xaml_path("select_project_window.xaml"))
+ self.selected_project = None
+ self.CreateProjectButton.Visibility = Visibility.Visible if config.can_create_project() else Visibility.Collapsed
+ theme.apply_theme(self)
+ self.refresh(use_server=False)
+
+ def refresh(self, use_server=True):
+ projects, status = project_service.load_projects(use_server=use_server)
+ self.ProjectsList.ItemsSource = [ProjectItem(x) for x in projects]
+ self.StatusText.Text = status or (u"Проектов: %s" % len(projects))
+
+ def refresh_click(self, sender, args):
+ self.refresh(use_server=True)
+
+ def create_project_click(self, sender, args):
+ if not config.can_create_project():
+ forms.alert(u"У текущей роли нет права создавать проекты", title="GIP Tasks")
+ return
+ win = CreateProjectWindow()
+ if win.show_dialog():
+ payload = task_models.new_project_payload(config.load(), win.result)
+ project, status = project_service.create_project(payload)
+ self.selected_project = project
+ self.StatusText.Text = status
+ self.DialogResult = True
+ self.Close()
+
+ def select_click(self, sender, args):
+ item = self.ProjectsList.SelectedItem
+ if not item:
+ forms.alert(u"Выберите проект", title="GIP Tasks")
+ return
+ self.selected_project = item.data
+ self.DialogResult = True
+ self.Close()
+
+ def cancel_click(self, sender, args):
+ self.DialogResult = False
+ self.Close()
diff --git a/pyrevit.extension/lib/gip_tasks/ui/select_project_window.xaml b/pyrevit.extension/lib/gip_tasks/ui/select_project_window.xaml
new file mode 100644
index 0000000..2ddd3f7
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/select_project_window.xaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyrevit.extension/lib/gip_tasks/ui/task_journal_window.py b/pyrevit.extension/lib/gip_tasks/ui/task_journal_window.py
new file mode 100644
index 0000000..0b08bb8
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/task_journal_window.py
@@ -0,0 +1,484 @@
+# -*- coding: utf-8 -*-
+import os
+try:
+ unicode
+except NameError:
+ unicode = str
+from datetime import datetime
+
+from System.Windows import Visibility
+from System.Collections.Generic import List
+from Autodesk.Revit.DB import ElementId
+from pyrevit import forms
+from gip_tasks import config, local_cache, marker_service, project_service, task_models, task_service
+from gip_tasks.ui import theme
+from gip_tasks.ui.create_project_window import CreateProjectWindow
+from gip_tasks.ui.select_project_window import ProjectItem
+
+
+def xaml_path(name):
+ return os.path.join(config.extension_root(), "lib", "gip_tasks", "ui", name)
+
+
+def _visible(value):
+ return Visibility.Visible if value else Visibility.Collapsed
+
+
+def _lower(value):
+ return (value or u"").lower()
+
+
+def _parse_due_date(value):
+ value = (value or u"").strip()
+ if not value:
+ return None
+ for fmt in ("%d.%m.%Y", "%d.%m.%Y %H:%M", "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
+ try:
+ return datetime.strptime(value[:len(datetime.now().strftime(fmt))], fmt).date()
+ except Exception:
+ pass
+ return None
+
+
+def _is_overdue(task):
+ due_date = _parse_due_date(task.get("due_date"))
+ if not due_date:
+ return False
+ marker_status = task.get("marker_status") or task_models.server_to_marker_status(task.get("status"))
+ if marker_status in (u"Отменено", u"Выполнено"):
+ return False
+ if task.get("status") in ("closed", "done", "completed", "cancelled"):
+ return False
+ return due_date < datetime.now().date()
+
+
+def display_status(task):
+ marker_status = task.get("marker_status") or task_models.server_to_marker_status(task.get("status"))
+ server_status = task.get("status")
+ if marker_status == u"Отменено" or server_status in ("cancelled", "returned"):
+ return u"Отменено"
+ if server_status in ("closed", "done", "completed", u"Выполнено"):
+ return u"Выполнено"
+ if _is_overdue(task):
+ return u"Просрочено"
+ if marker_status == u"Принято" or server_status in ("in_progress", "review"):
+ return u"В работе"
+ return u"Ожидает"
+
+
+def priority_label(value):
+ value = value or u""
+ lowered = value.lower()
+ if lowered == u"срочный":
+ return u"Срочный"
+ if lowered == u"низкий":
+ return u"Низкий"
+ if lowered == u"высокий":
+ return u"Высокий"
+ return u"Обычный"
+
+
+def priority_colors(value):
+ label = priority_label(value)
+ if label == u"Срочный":
+ return "#FEE4E2", "#B42318"
+ if label == u"Низкий":
+ return "#ECFDF3", "#027A48"
+ if label == u"Высокий":
+ return "#FEF3C7", "#92400E"
+ return "#E0EAFF", "#3136FF"
+
+
+def status_colors(value):
+ if value == u"Просрочено":
+ return "#FEE4E2", "#B42318"
+ if value == u"Отменено":
+ return "#F2F4F7", "#475467"
+ if value == u"Выполнено":
+ return "#ECFDF3", "#027A48"
+ if value == u"В работе":
+ return "#E0EAFF", "#3136FF"
+ return "#FEF0C7", "#B54708"
+
+
+def project_display_text(task):
+ code = task.get("project_code") or ""
+ name = task.get("project_name") or task.get("project_id") or ""
+ return ("%s - %s" % (code, name)).strip(" -")
+
+
+class TaskRow(object):
+ def __init__(self, data):
+ self.data = data
+ tid = data.get("id") or data.get("temporary_id") or ""
+ self.Number = tid[:8]
+ self.Route = u"%s → %s" % (data.get("sender_discipline") or "", data.get("receiver_discipline") or "")
+ self.Type = data.get("type") or u"Задание"
+ self.DueDate = data.get("due_date") or u""
+ self.Priority = priority_label(data.get("priority"))
+ self.Status = display_status(data)
+ self.PriorityBackground, self.PriorityForeground = priority_colors(data.get("priority"))
+ self.StatusBackground, self.StatusForeground = status_colors(self.Status)
+ self.RowStripBrush = "#E2E5F0"
+ self.Level = data.get("level_name") or ""
+ self.View = data.get("view_name") or ""
+ self.Description = (data.get("description") or "").replace("\n", " ")[:140]
+
+
+class TaskJournalWindow(forms.WPFWindow):
+ def __init__(self, uidoc):
+ forms.WPFWindow.__init__(self, xaml_path("task_journal_window.xaml"))
+ self.uidoc = uidoc
+ self.settings = config.load()
+ self.tasks = []
+ self.selected_task = None
+ self.active_quick_tab = "all"
+ self._bind_filters()
+ theme.apply_theme(self)
+ self.refresh_projects(use_server=False)
+ self.load_tasks(use_server=False)
+ self._set_active_tab_visuals()
+ self._show_card()
+ try:
+ def _loaded(sender, args):
+ self._set_active_tab_visuals()
+ self._update_search_placeholder()
+ self._show_card()
+ self.Loaded += _loaded
+ self._gip_journal_loaded_handler = _loaded
+ except Exception:
+ pass
+
+ def _bind_filters(self):
+ disciplines = [u"Все разделы"] + task_models.DISCIPLINES
+ self.SenderFilter.ItemsSource = disciplines
+ self.ReceiverFilter.ItemsSource = disciplines
+ self.StatusFilter.ItemsSource = [u"Все статусы", u"Ожидает", u"В работе", u"Выполнено", u"Отменено", u"Просрочено"]
+ self.PriorityFilter.ItemsSource = [u"Все"] + task_models.PRIORITIES
+ self.SenderFilter.SelectedIndex = 0
+ self.ReceiverFilter.SelectedIndex = 0
+ self.StatusFilter.SelectedIndex = 0
+ self.PriorityFilter.SelectedIndex = 0
+ self.SearchBox.Text = ""
+ self.CreateProjectButton.Visibility = _visible(config.can_create_project(self.settings))
+
+ def _mode_text(self, status):
+ status = status or u""
+ lowered = status.lower()
+ if config.is_local_mode(self.settings):
+ return u"Локальный режим"
+ if u"недоступен" in lowered:
+ return u"Сервер недоступен"
+ if u"кэш" in lowered:
+ return u"Локальный кэш"
+ if u"локальный режим" in lowered:
+ return u"Локальный режим"
+ return u"Сервер подключен"
+
+ def refresh_projects(self, use_server=True):
+ projects, status = project_service.load_projects(use_server=use_server)
+ self.ProjectCombo.ItemsSource = [ProjectItem(x) for x in projects]
+ current_id = self.settings.get("CURRENT_PROJECT_ID")
+ for item in self.ProjectCombo.ItemsSource or []:
+ if item.data.get("id") == current_id:
+ self.ProjectCombo.SelectedItem = item
+ break
+ mode_text = self._mode_text(status)
+ self.ConnectionStatusText.Text = mode_text
+ theme.set_connection_state(self.ConnectionDot, mode_text)
+ self.StatusText.Text = status or self._queue_status()
+
+ def _queue_status(self):
+ project_id = self.settings.get("CURRENT_PROJECT_ID")
+ if not project_id:
+ return u"Сначала выберите проект"
+ count = local_cache.pending_count(project_id)
+ return u"Ожидает отправки: %s" % count if count else u""
+
+ def project_changed(self, sender, args):
+ item = self.ProjectCombo.SelectedItem
+ if item:
+ self.settings = project_service.choose_project(item.data)
+ self.load_tasks(use_server=False)
+
+ def _matches_quick_tab(self, task):
+ discipline = self.settings.get("CURRENT_DISCIPLINE") or u""
+ tab = self.active_quick_tab
+ if tab == "mine":
+ return task.get("receiver_discipline") == discipline
+ if tab == "from_me":
+ return task.get("sender_discipline") == discipline
+ if tab == "urgent":
+ return _lower(task.get("priority")) == u"срочный"
+ if tab == "overdue":
+ return display_status(task) == u"Просрочено"
+ if tab == "cancelled":
+ return display_status(task) == u"Отменено"
+ return True
+
+ def _filtered_rows(self):
+ text = (self.SearchBox.Text or "").lower()
+ status = self.StatusFilter.SelectedItem
+ sender = self.SenderFilter.SelectedItem
+ receiver = self.ReceiverFilter.SelectedItem
+ priority = self.PriorityFilter.SelectedItem
+ rows = []
+ for task in self.tasks:
+ if not self._matches_quick_tab(task):
+ continue
+ marker_status = display_status(task)
+ if status and status != u"Все статусы" and marker_status != status:
+ continue
+ if sender and sender != u"Все разделы" and task.get("sender_discipline") != sender:
+ continue
+ if receiver and receiver != u"Все разделы" and task.get("receiver_discipline") != receiver:
+ continue
+ if priority and priority != u"Все" and task.get("priority") != priority:
+ continue
+ hay = u"%s %s %s %s %s" % (
+ task.get("description") or "",
+ task.get("type") or "",
+ task.get("view_name") or "",
+ task.get("level_name") or "",
+ project_display_text(task),
+ )
+ if text and text not in hay.lower():
+ continue
+ rows.append(TaskRow(task))
+ return rows
+
+ def _set_empty_state(self, rows, has_project=True):
+ self.EmptyStatePanel.Visibility = _visible(has_project and not rows)
+
+ def load_tasks(self, use_server=True):
+ project_id = self.settings.get("CURRENT_PROJECT_ID")
+ if not project_id:
+ self.tasks = []
+ self.TasksList.ItemsSource = []
+ self.selected_task = None
+ self._set_empty_state([], has_project=False)
+ self.StatusText.Text = u"Сначала выберите проект"
+ self._show_card()
+ return
+ self.tasks, status = task_service.list_tasks(project_id, use_server=use_server)
+ self._apply_filters(status)
+
+ def _apply_filters(self, status_text=None):
+ rows = self._filtered_rows()
+ self.TasksList.ItemsSource = rows
+ self._set_empty_state(rows, has_project=bool(self.settings.get("CURRENT_PROJECT_ID")))
+ if status_text is not None:
+ self.StatusText.Text = status_text or self._queue_status() or u"Заданий: %s" % len(self.tasks)
+ else:
+ self.StatusText.Text = self._queue_status() or u"Заданий: %s" % len(rows)
+ if self.selected_task:
+ keep_id = self.selected_task.get("id") or self.selected_task.get("temporary_id")
+ for row in rows:
+ row_id = row.data.get("id") or row.data.get("temporary_id")
+ if row_id == keep_id:
+ self.TasksList.SelectedItem = row
+ self.selected_task = row.data
+ self._show_card()
+ return
+ if rows:
+ self.TasksList.SelectedItem = rows[0]
+ self.selected_task = rows[0].data
+ self._show_card()
+ return
+ self.selected_task = None
+ self._show_card()
+
+ def filter_changed(self, sender, args):
+ self._update_search_placeholder()
+ self._apply_filters()
+
+ def _update_search_placeholder(self):
+ try:
+ self.SearchPlaceholder.Visibility = _visible(not bool((self.SearchBox.Text or "").strip()))
+ except Exception:
+ pass
+
+ def reset_filters_click(self, sender, args):
+ self.SearchBox.Text = ""
+ self.SenderFilter.SelectedIndex = 0
+ self.ReceiverFilter.SelectedIndex = 0
+ self.StatusFilter.SelectedIndex = 0
+ self.PriorityFilter.SelectedIndex = 0
+ self.active_quick_tab = "all"
+ self._set_active_tab_visuals()
+ self._update_search_placeholder()
+ self._apply_filters()
+
+ def quick_tab_click(self, sender, args):
+ try:
+ self.active_quick_tab = str(sender.CommandParameter)
+ except Exception:
+ self.active_quick_tab = "all"
+ self._set_active_tab_visuals()
+ self._apply_filters()
+
+ def _tab_buttons(self):
+ return {
+ "all": self.TabAllButton,
+ "mine": self.TabMineButton,
+ "from_me": self.TabFromMeButton,
+ "urgent": self.TabUrgentButton,
+ "overdue": self.TabOverdueButton,
+ "cancelled": self.TabCancelledButton,
+ }
+
+ def _set_active_tab_visuals(self):
+ for key, button in self._tab_buttons().items():
+ theme.set_quick_tab_state(button, key == self.active_quick_tab)
+
+ def create_project_click(self, sender, args):
+ win = CreateProjectWindow()
+ if win.show_dialog():
+ payload = task_models.new_project_payload(config.load(), win.result)
+ project, status = project_service.create_project(payload)
+ self.settings = project_service.choose_project(project)
+ self.refresh_projects(use_server=False)
+ self.load_tasks(use_server=False)
+ self.StatusText.Text = status
+
+ def new_task_click(self, sender, args):
+ from gip_tasks import commands
+ payload = commands.run_create_task(self.uidoc)
+ if payload:
+ self.settings = config.load()
+ self.refresh_projects(use_server=False)
+ self.load_tasks(use_server=False)
+
+ def refresh_click(self, sender, args):
+ if not self.settings.get("CURRENT_PROJECT_ID"):
+ forms.alert(u"Сначала выберите проект", title="GIP Tasks")
+ return
+ sent = project_service.sync_pending(self.settings.get("CURRENT_PROJECT_ID"))
+ self.refresh_projects(use_server=True)
+ self.load_tasks(use_server=True)
+ self.StatusText.Text = u"Синхронизировано" if sent else (self.StatusText.Text or u"Синхронизировано")
+
+ def task_selected(self, sender, args):
+ row = self.TasksList.SelectedItem
+ self.selected_task = row.data if row else None
+ self._show_card()
+
+ def _set_card_badges(self, status, priority):
+ bg, fg = status_colors(status)
+ theme.set_badge(self.CardStatusBadge, self.CardStatusText, bg, fg)
+ bg, fg = priority_colors(priority)
+ theme.set_badge(self.CardPriorityBadge, self.CardPriorityText, bg, fg)
+
+ def _show_card(self):
+ task = self.selected_task
+ if not task:
+ self.CardEmptyStatePanel.Visibility = Visibility.Visible
+ self.CardDetailsPanel.Visibility = Visibility.Collapsed
+ self.CardActionsPanel.Visibility = Visibility.Collapsed
+ self.CardType.Text = u"Выберите задание"
+ self.CardStatusText.Text = u""
+ self.CardProject.Text = u""
+ self.CardRoute.Text = u""
+ self.CardDueDate.Text = u""
+ self.CardPriorityText.Text = u""
+ self.CardView.Text = u""
+ self.CardLevel.Text = u""
+ self.CardDescription.Text = u""
+ self.CardMarker.Text = u""
+ self.CommentBox.Text = u""
+ self.CardMarkerParams.Text = u""
+ self.AcceptButton.IsEnabled = False
+ self.CancelTaskButton.IsEnabled = False
+ self.FindMarkerButton.IsEnabled = False
+ return
+ self.CardEmptyStatePanel.Visibility = Visibility.Collapsed
+ self.CardDetailsPanel.Visibility = Visibility.Visible
+ self.CardActionsPanel.Visibility = Visibility.Visible
+ status = display_status(task)
+ marker_name = task.get("marker_family_name") or u"CPSK_Маркер задания"
+ marker_id = task.get("marker_element_id") or u""
+ route = u"%s → %s" % (task.get("sender_discipline") or "", task.get("receiver_discipline") or "")
+ self.CardType.Text = task.get("type") or u"Задание"
+ self.CardStatusText.Text = status
+ self.CardProject.Text = u"Проект: %s" % (project_display_text(task) or self.settings.get("CURRENT_PROJECT_NAME") or u"не задан")
+ self.CardRoute.Text = route
+ self.CardDueDate.Text = u"Срок: %s" % (task.get("due_date") or u"не задан")
+ self.CardPriorityText.Text = priority_label(task.get("priority"))
+ self.CardView.Text = task.get("view_name") or u"не задано"
+ self.CardLevel.Text = task.get("level_name") or u"не задано"
+ self.CardDescription.Text = task.get("description") or u""
+ self.CommentBox.Text = task.get("comment") or u""
+ self._update_card_comment_placeholder()
+ self.CardMarker.Text = u"%s · ID %s" % (marker_name, marker_id) if marker_id else marker_name
+ self.CardMarkerParams.Text = u"Параметры маркера обновляются при записи задания в семейство."
+ self._set_card_badges(status, task.get("priority"))
+ inactive = status in (u"Отменено", u"Выполнено")
+ self.AcceptButton.IsEnabled = not inactive
+ self.CancelTaskButton.IsEnabled = not inactive
+ self.FindMarkerButton.IsEnabled = bool(task.get("marker_unique_id"))
+
+ def _update_card_comment_placeholder(self):
+ try:
+ self.CardCommentPlaceholder.Visibility = _visible(not bool((self.CommentBox.Text or "").strip()))
+ except Exception:
+ pass
+
+ def comment_text_changed(self, sender, args):
+ self._update_card_comment_placeholder()
+
+ def _selected_or_warn(self):
+ if not self.selected_task:
+ forms.alert(u"Выберите задание", title="GIP Tasks")
+ return None
+ return self.selected_task
+
+ def _update_marker_for_task(self, task):
+ marker = marker_service.find_marker_by_unique_id(self.uidoc.Document, task.get("marker_unique_id"))
+ if marker:
+ marker_service.write_task_to_marker(self.uidoc.Document, marker, task, update_date=False)
+
+ def accept_click(self, sender, args):
+ task = self._selected_or_warn()
+ if not task:
+ return
+ if display_status(task) in (u"Отменено", u"Выполнено"):
+ forms.alert(u"Для завершенного или отмененного задания действие недоступно", title="GIP Tasks")
+ return
+ self.selected_task, status = task_service.update_status(task, u"Принято")
+ self._update_marker_for_task(self.selected_task)
+ self.load_tasks(use_server=False)
+ self.StatusText.Text = status
+
+ def cancel_task_click(self, sender, args):
+ task = self._selected_or_warn()
+ if not task:
+ return
+ if display_status(task) in (u"Отменено", u"Выполнено"):
+ forms.alert(u"Для завершенного или отмененного задания действие недоступно", title="GIP Tasks")
+ return
+ self.selected_task, status = task_service.update_status(task, u"Отменено")
+ self._update_marker_for_task(self.selected_task)
+ self.load_tasks(use_server=False)
+ self.StatusText.Text = status
+
+ def comment_click(self, sender, args):
+ task = self._selected_or_warn()
+ if not task:
+ return
+ self.selected_task, status = task_service.add_comment(task, self.CommentBox.Text or "")
+ self._update_marker_for_task(self.selected_task)
+ self.load_tasks(use_server=False)
+ self.StatusText.Text = status
+
+ def find_marker_click(self, sender, args):
+ task = self._selected_or_warn()
+ if not task:
+ return
+ marker = marker_service.find_marker_by_unique_id(self.uidoc.Document, task.get("marker_unique_id"))
+ if not marker:
+ forms.alert(u"Маркер не найден в текущей модели", title="GIP Tasks")
+ return
+ ids = List[ElementId]()
+ ids.Add(marker.Id)
+ self.uidoc.Selection.SetElementIds(ids)
+ forms.alert(u"Маркер выбран в модели", title="GIP Tasks", warn_icon=False)
diff --git a/pyrevit.extension/lib/gip_tasks/ui/task_journal_window.xaml b/pyrevit.extension/lib/gip_tasks/ui/task_journal_window.xaml
new file mode 100644
index 0000000..d8ca77c
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/task_journal_window.xaml
@@ -0,0 +1,401 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyrevit.extension/lib/gip_tasks/ui/theme.py b/pyrevit.extension/lib/gip_tasks/ui/theme.py
new file mode 100644
index 0000000..f1f8793
--- /dev/null
+++ b/pyrevit.extension/lib/gip_tasks/ui/theme.py
@@ -0,0 +1,309 @@
+# -*- coding: utf-8 -*-
+try:
+ from System import Enum
+ from System.Windows import CornerRadius, Setter, Style, Thickness, Trigger
+ from System.Windows.Controls import Border, Button, CheckBox, ComboBox, DataGrid, DataGridColumnHeader, DatePicker, GridViewColumnHeader, ListBox, ListView, TextBlock, TextBox
+ from System.Windows.Media import ColorConverter, SolidColorBrush, VisualTreeHelper
+except Exception:
+ Enum = None
+ CornerRadius = Setter = Style = Thickness = Trigger = None
+ Border = Button = CheckBox = ComboBox = DataGrid = DataGridColumnHeader = DatePicker = GridViewColumnHeader = ListBox = ListView = TextBlock = TextBox = None
+ ColorConverter = SolidColorBrush = VisualTreeHelper = None
+
+
+LIGHT = {
+ "window": "#F7F8FC",
+ "surface": "#FFFFFF",
+ "panel": "#F1F4FF",
+ "input": "#FFFFFF",
+ "text": "#1F2430",
+ "muted": "#6B7280",
+ "border": "#E2E5F0",
+ "primary": "#3136FF",
+ "primary_hover": "#252AE6",
+ "secondary": "#5331FF",
+ "accent": "#660FD1",
+ "secondary_hover": "#EEF2FF",
+ "button_text": "#FFFFFF",
+ "success": "#16A34A",
+ "offline": "#9CA3AF",
+ "danger": "#DC2626",
+}
+
+DARK = {
+ "window": "#111827",
+ "surface": "#172033",
+ "panel": "#1E1B4B",
+ "input": "#0F172A",
+ "text": "#E5E7EB",
+ "muted": "#A5B4FC",
+ "border": "#374151",
+ "primary": "#3136FF",
+ "primary_hover": "#5331FF",
+ "secondary": "#5331FF",
+ "accent": "#A78BFA",
+ "secondary_hover": "#27324A",
+ "button_text": "#FFFFFF",
+ "success": "#22C55E",
+ "offline": "#9CA3AF",
+ "danger": "#F87171",
+}
+
+
+def _brush(hex_value):
+ return SolidColorBrush(ColorConverter.ConvertFromString(hex_value))
+
+
+def _safe_set(obj, name, value):
+ try:
+ setattr(obj, name, value)
+ except Exception:
+ pass
+
+
+def _tag_value(control):
+ try:
+ return str(control.Tag)
+ except Exception:
+ return ""
+
+
+def _should_keep_style(control):
+ tag = _tag_value(control).lower()
+ return tag in ("keepstyle", "no_theme", "notheme")
+
+
+def is_dark_theme():
+ try:
+ from Autodesk.Revit.UI import UIThemeManager
+ theme = str(UIThemeManager.CurrentTheme).lower()
+ if "dark" in theme:
+ return True
+ if "light" in theme:
+ return False
+ except Exception:
+ pass
+ try:
+ from Microsoft.Win32 import Registry
+ key = Registry.CurrentUser.OpenSubKey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
+ value = key.GetValue("AppsUseLightTheme")
+ return int(value) == 0
+ except Exception:
+ return False
+
+
+def _children(element):
+ if VisualTreeHelper is None:
+ return []
+ try:
+ count = VisualTreeHelper.GetChildrenCount(element)
+ return [VisualTreeHelper.GetChild(element, i) for i in range(count)]
+ except Exception:
+ return []
+
+
+def _walk(element):
+ yield element
+ for child in _children(element):
+ for item in _walk(child):
+ yield item
+
+
+def _button_style(brushes, primary=False):
+ if Style is None or Setter is None or Trigger is None or Button is None:
+ return None
+ style = Style(Button)
+ if primary:
+ background = brushes["primary"]
+ foreground = brushes["button_text"]
+ border = brushes["primary_hover"]
+ hover_background = brushes["primary_hover"]
+ hover_border = brushes["accent"]
+ pressed_background = brushes["accent"]
+ else:
+ background = brushes["surface"]
+ foreground = brushes["text"]
+ border = brushes["border"]
+ hover_background = brushes["secondary_hover"]
+ hover_border = brushes["primary"]
+ pressed_background = brushes["panel"]
+ style.Setters.Add(Setter(Button.BackgroundProperty, background))
+ style.Setters.Add(Setter(Button.ForegroundProperty, foreground))
+ style.Setters.Add(Setter(Button.BorderBrushProperty, border))
+ style.Setters.Add(Setter(Button.PaddingProperty, Thickness(10, 3, 10, 3)))
+ style.Setters.Add(Setter(Button.MinHeightProperty, 28))
+ hover = Trigger()
+ hover.Property = Button.IsMouseOverProperty
+ hover.Value = True
+ hover.Setters.Add(Setter(Button.BackgroundProperty, hover_background))
+ hover.Setters.Add(Setter(Button.BorderBrushProperty, hover_border))
+ style.Triggers.Add(hover)
+ pressed = Trigger()
+ pressed.Property = Button.IsPressedProperty
+ pressed.Value = True
+ pressed.Setters.Add(Setter(Button.BackgroundProperty, pressed_background))
+ style.Triggers.Add(pressed)
+ return style
+
+
+def _style_tree(window, brushes, primary_button_style=None, secondary_button_style=None):
+ for control in _walk(window):
+ if _should_keep_style(control):
+ continue
+ if Border is not None and isinstance(control, Border):
+ _safe_set(control, "Background", brushes["surface"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "CornerRadius", CornerRadius(8))
+ elif Button is not None and isinstance(control, Button):
+ name = getattr(control, "Name", "")
+ style = primary_button_style if name in ("SendButton", "NewTaskButton", "PrimaryButton") else secondary_button_style
+ if style is not None:
+ _safe_set(control, "Style", style)
+ if name == "FindMarkerButton":
+ _safe_set(control, "Foreground", brushes["accent"])
+ _safe_set(control, "BorderBrush", brushes["accent"])
+ _safe_set(control, "Background", brushes["surface"])
+ elif TextBox is not None and isinstance(control, TextBox):
+ _safe_set(control, "Background", brushes["input"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "Padding", Thickness(6, 3, 6, 3))
+ elif ComboBox is not None and isinstance(control, ComboBox):
+ _safe_set(control, "Background", brushes["input"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "Padding", Thickness(4, 2, 4, 2))
+ _safe_set(control, "MinHeight", 26)
+ elif DatePicker is not None and isinstance(control, DatePicker):
+ _safe_set(control, "Background", brushes["input"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "MinHeight", 28)
+ elif ListBox is not None and isinstance(control, ListBox):
+ _safe_set(control, "Background", brushes["surface"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ elif ListView is not None and isinstance(control, ListView):
+ _safe_set(control, "Background", brushes["surface"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ elif DataGrid is not None and isinstance(control, DataGrid):
+ _safe_set(control, "Background", brushes["surface"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "RowBackground", brushes["surface"])
+ _safe_set(control, "AlternatingRowBackground", brushes["window"])
+ elif GridViewColumnHeader is not None and isinstance(control, GridViewColumnHeader):
+ _safe_set(control, "Background", brushes["panel"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "Padding", Thickness(6, 4, 6, 4))
+ elif DataGridColumnHeader is not None and isinstance(control, DataGridColumnHeader):
+ _safe_set(control, "Background", brushes["panel"])
+ _safe_set(control, "Foreground", brushes["text"])
+ _safe_set(control, "BorderBrush", brushes["border"])
+ _safe_set(control, "Padding", Thickness(6, 4, 6, 4))
+ _safe_set(control, "MinHeight", 28)
+ elif CheckBox is not None and isinstance(control, CheckBox):
+ _safe_set(control, "Foreground", brushes["text"])
+ elif TextBlock is not None and isinstance(control, TextBlock):
+ pass
+
+ for name in ("ProjectText", "StatusText", "MarkerIdText", "ModeBadge", "ConnectionStatusText"):
+ try:
+ getattr(window, name).Foreground = brushes["accent" if name in ("ProjectText", "ModeBadge") else "muted"]
+ except Exception:
+ pass
+
+
+def apply_theme(window):
+ if SolidColorBrush is None:
+ return
+ palette = DARK if is_dark_theme() else LIGHT
+ brushes = {}
+ for key, value in palette.items():
+ brushes[key] = _brush(value)
+
+ _safe_set(window, "Background", brushes["window"])
+ _safe_set(window, "Foreground", brushes["text"])
+
+ try:
+ window.Resources["GIP_WindowBrush"] = brushes["window"]
+ window.Resources["GIP_SurfaceBrush"] = brushes["surface"]
+ window.Resources["GIP_PanelBrush"] = brushes["panel"]
+ window.Resources["GIP_InputBrush"] = brushes["input"]
+ window.Resources["GIP_TextBrush"] = brushes["text"]
+ window.Resources["GIP_MutedBrush"] = brushes["muted"]
+ window.Resources["GIP_BorderBrush"] = brushes["border"]
+ window.Resources["GIP_PrimaryBrush"] = brushes["primary"]
+ window.Resources["GIP_SecondaryBrush"] = brushes["secondary"]
+ window.Resources["GIP_AccentBrush"] = brushes["accent"]
+ except Exception:
+ pass
+
+ primary_button_style = _button_style(brushes, primary=True)
+ secondary_button_style = _button_style(brushes, primary=False)
+ _style_tree(window, brushes, primary_button_style, secondary_button_style)
+ try:
+ def _loaded(sender, args):
+ _style_tree(window, brushes, primary_button_style, secondary_button_style)
+ window.Loaded += _loaded
+ window._gip_theme_loaded_handler = _loaded
+ except Exception:
+ pass
+
+
+def set_connection_state(dot, text):
+ if SolidColorBrush is None or dot is None:
+ return
+ lowered = (text or "").lower()
+ color = LIGHT["success"]
+ if u"недоступен" in lowered:
+ color = LIGHT["danger"]
+ elif u"локальный" in lowered or u"кэш" in lowered:
+ color = LIGHT["offline"]
+ try:
+ dot.Background = _brush(color)
+ except Exception:
+ pass
+
+
+def set_quick_tab_state(button, active):
+ if SolidColorBrush is None or button is None:
+ return
+ try:
+ button.Foreground = _brush(LIGHT["primary"] if active else LIGHT["muted"])
+ button.BorderBrush = _brush(LIGHT["primary"] if active else "#00FFFFFF")
+ button.Background = _brush("#F4F5FF" if active else "#00FFFFFF")
+ button.BorderThickness = Thickness(0, 0, 0, 2) if active else Thickness(0)
+ except Exception:
+ pass
+
+
+def set_chip_state(button, active):
+ if SolidColorBrush is None or button is None:
+ return
+ try:
+ if not button.IsEnabled:
+ button.Foreground = _brush("#A0A7B5")
+ button.BorderBrush = _brush("#E5E7EB")
+ button.Background = _brush("#F5F6FA")
+ return
+ button.Foreground = _brush(LIGHT["primary"] if active else LIGHT["text"])
+ button.BorderBrush = _brush(LIGHT["primary"] if active else LIGHT["border"])
+ button.Background = _brush("#F4F5FF" if active else LIGHT["surface"])
+ except Exception:
+ pass
+
+
+def set_badge(border, text_block, background, foreground):
+ if SolidColorBrush is None:
+ return
+ try:
+ if border is not None:
+ border.Background = _brush(background)
+ border.BorderBrush = _brush(background)
+ if text_block is not None:
+ text_block.Foreground = _brush(foreground)
+ except Exception:
+ pass