From e238c00a30f4df4cd24a68524abaab4f59e6fd37 Mon Sep 17 00:00:00 2001 From: "Ivan I. Ovchinnikov" Date: Wed, 25 Mar 2026 22:05:38 +0300 Subject: [PATCH] bitrix tasks are created with comments (imported only 1-by-1) --- Cargo.lock | 1 + Cargo.toml | 2 +- src/handlers/import.rs | 219 +++++++++++++++++++++- src/main.rs | 17 +- src/services/bitrix24.rs | 352 +++++++++++++++++++++++++++++------ src/services/redmine.rs | 54 ++++++ templates/import_select.html | 79 ++++++-- 7 files changed, 643 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86fe650..61ea447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1201,6 +1201,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", diff --git a/Cargo.toml b/Cargo.toml index 777daca..861b938 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ tower = "0.4" tower-http = { version = "0.5", features = ["fs", "trace", "cors"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", features = ["json", "multipart"] } dotenvy = "0.15" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/handlers/import.rs b/src/handlers/import.rs index b9db083..96070e5 100644 --- a/src/handlers/import.rs +++ b/src/handlers/import.rs @@ -7,6 +7,48 @@ use axum::response::{Html, IntoResponse}; use serde::Deserialize; use tower_sessions::Session; +fn deserialize_task_ids<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + use std::fmt; + + struct TaskIdsVisitor; + + impl<'de> Visitor<'de> for TaskIdsVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string or sequence of strings") + } + + fn visit_str(self, value: &str) -> Result, E> + where + E: de::Error, + { + if value.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![value.to_string()]) + } + } + + fn visit_seq(self, mut seq: A) -> Result, A::Error> + where + A: de::SeqAccess<'de>, + { + let mut task_ids = Vec::new(); + while let Some(id) = seq.next_element::()? { + task_ids.push(id); + } + Ok(task_ids) + } + } + + deserializer.deserialize_any(TaskIdsVisitor) +} + #[derive(Template)] #[template(path = "import_select.html")] pub struct ImportSelectTemplate { @@ -34,10 +76,14 @@ pub struct ImportResult { #[derive(Debug, Deserialize)] pub struct ImportForm { pub project_id: String, + #[serde(deserialize_with = "deserialize_task_ids")] pub task_ids: Vec, + pub import_comments: Option, } pub async fn import_select(session: Session) -> impl IntoResponse { + tracing::info!("📥 Запрос страницы импорта"); + let settings = match session .get::("user_settings") .await @@ -136,6 +182,17 @@ pub async fn import_select(session: Session) -> impl IntoResponse { } pub async fn import_tasks(session: Session, Form(form): Form) -> impl IntoResponse { + eprintln!("========================================"); + eprintln!("=== НАЧАЛО ИМПОРТА ==="); + eprintln!("========================================"); + + tracing::info!("========================================"); + tracing::info!("=== НАЧАЛО ИМПОРТА ==="); + tracing::info!("========================================"); + tracing::info!("form.project_id = {}", form.project_id); + tracing::info!("form.task_ids = {:?}", form.task_ids); + tracing::info!("form.import_comments = {:?}", form.import_comments); + let settings = match session .get::("user_settings") .await @@ -143,10 +200,19 @@ pub async fn import_tasks(session: Session, Form(form): Form) -> imp { Some(s) => s, None => { + eprintln!("❌ ОШИБКА: Настройки не найдены"); return Html("
Сначала настройте подключение
".to_string()); } }; + let bitrix_user_id = bitrix24::extract_user_id_from_webhook(&settings.bitrix_webhook) + .unwrap_or(settings.redmine_user_id); + + tracing::info!("Bitrix user ID: {}", bitrix_user_id); + + let import_comments = true; + tracing::info!("Импорт комментариев: {} (принудительно)", import_comments); + let project_id: u32 = match form.project_id.parse() { Ok(id) => id, Err(_) => { @@ -173,14 +239,31 @@ pub async fn import_tasks(session: Session, Form(form): Form) -> imp } }; + tracing::info!("----------------------------------------"); + tracing::info!("Обработка задачи Redmine #{}", task_id); + eprintln!("📋 Обработка задачи Redmine #{}", task_id); + match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, task_id) .await { Ok(issue) => { + let attachments_count = issue.attachments.as_ref().map(|a| a.len()).unwrap_or(0); + let journals_count = issue.journals.as_ref().map(|j| j.len()).unwrap_or(0); + + tracing::info!( + "Задача получена: вложений={}, журналов={}", + attachments_count, + journals_count + ); + eprintln!( + " Вложений: {}, Журналов: {}", + attachments_count, journals_count + ); + let description = format!( "

Импортировано из Redmine #{}:

{}", issue.id, - issue.description.unwrap_or_default() + issue.description.clone().unwrap_or_default() ); match bitrix24::create_task( @@ -188,13 +271,137 @@ pub async fn import_tasks(session: Session, Form(form): Form) -> imp &settings.bitrix_webhook, &issue.subject, &description, - settings.redmine_user_id, + bitrix_user_id, project_id, issue.due_date.as_deref(), ) .await { Ok(bitrix_task_id) => { + tracing::info!("✅ Задача Bitrix24 #{} создана", bitrix_task_id); + eprintln!(" ✅ Задача Bitrix24 #{} создана", bitrix_task_id); + + if import_comments { + eprintln!(" 📋 Начало импорта комментариев..."); + + let mut file_doc_ids: Vec = Vec::new(); + + if let Some(attachments) = &issue.attachments { + eprintln!(" 📎 Вложений: {}", attachments.len()); + + for attachment in attachments { + if let Some(content_url) = &attachment.content_url { + eprintln!(" Загрузка: {}", attachment.filename); + + match redmine::download_attachment( + content_url, + &settings.redmine_api_key, + ) + .await + { + Ok(file_content) => { + eprintln!( + " Скачано: {} байт", + file_content.len() + ); + + match bitrix24::upload_attachment( + &settings.bitrix_url, + &settings.bitrix_webhook, + &attachment.filename, + &file_content, + ) + .await + { + Ok(file_doc_id) => { + eprintln!( + " ✅ Загружено с ID: {}", + file_doc_id + ); + file_doc_ids.push(file_doc_id); + } + Err(e) => { + eprintln!(" ❌ Ошибка: {}", e); + tracing::error!("Ошибка загрузки: {}", e); + } + } + } + Err(e) => { + eprintln!(" ❌ Не удалось скачать: {}", e); + } + } + } + } + } + + if let Some(desc) = &issue.description { + if !desc.is_empty() { + eprintln!(" 💬 Добавляем описание..."); + + match bitrix24::add_comment_with_attachments( + &settings.bitrix_url, + &settings.bitrix_webhook, + bitrix_task_id, + desc, + file_doc_ids.clone(), + bitrix_user_id, + None, // Убрали дату + ) + .await + { + Ok(_) => { + eprintln!(" ✅ Описание добавлено"); + tracing::info!("Описание добавлено"); + } + Err(e) => { + eprintln!(" ❌ Ошибка: {}", e); + tracing::error!("Ошибка описания: {}", e); + } + } + } + } + + if let Some(journals) = &issue.journals { + eprintln!(" 📝 Журналов: {}", journals.len()); + + for journal in journals { + if let Some(notes) = &journal.notes { + if !notes.is_empty() { + let user_name = journal + .user + .as_ref() + .map(|u| u.name.as_str()) + .unwrap_or("Система"); + + eprintln!(" Комментарий от {}...", user_name); + + match bitrix24::add_comment( + &settings.bitrix_url, + &settings.bitrix_webhook, + bitrix_task_id, + notes, // Просто текст, без HTML + bitrix_user_id, + None, // Убрали дату + ) + .await + { + Ok(_) => { + eprintln!(" ✅ Добавлен"); + tracing::info!("Комментарий добавлен"); + } + Err(e) => { + eprintln!(" ❌ Ошибка: {}", e); + tracing::error!("Ошибка комментария: {}", e); + } + } + } + } + } + } + + eprintln!(" 📋 Импорт завершён"); + } + success_count += 1; results.push(ImportResult { task_id: issue.id, @@ -204,6 +411,7 @@ pub async fn import_tasks(session: Session, Form(form): Form) -> imp }); } Err(e) => { + eprintln!(" ❌ Ошибка: {}", e); error_count += 1; results.push(ImportResult { task_id: issue.id, @@ -215,17 +423,22 @@ pub async fn import_tasks(session: Session, Form(form): Form) -> imp } } Err(e) => { + eprintln!(" ❌ Ошибка: {}", e); error_count += 1; results.push(ImportResult { task_id, task_title: "Неизвестно".to_string(), bitrix_id: None, - error: Some(format!("Ошибка получения задачи: {}", e)), + error: Some(format!("Ошибка: {}", e)), }); } } } + eprintln!("========================================"); + eprintln!("Успешно: {}, Ошибок: {}", success_count, error_count); + eprintln!("========================================"); + let template = ImportResultTemplate { success_count, error_count, diff --git a/src/main.rs b/src/main.rs index 6c5f66e..6b34f50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,12 +15,18 @@ pub mod services; #[tokio::main] async fn main() { + // ИНИЦИАЛИЗАЦИЯ ЛОГИРОВАНИЯ - ОБЯЗАТЕЛЬНО! tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), - )) + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) .init(); + tracing::info!("========================================"); + tracing::info!("=== ЗАПУСК BITMINE ==="); + tracing::info!("========================================"); + dotenvy::dotenv().ok(); let session_store = MemoryStore::default(); @@ -50,8 +56,11 @@ async fn main() { ); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); - tracing::info!("Listening on {}", addr); + tracing::info!("🚀 Сервер запускается на {}", addr); let listener = TcpListener::bind(addr).await.unwrap(); + tracing::info!("✅ Сервер готов к работе"); + tracing::info!("========================================"); + axum::serve(listener, app).await.unwrap(); } diff --git a/src/services/bitrix24.rs b/src/services/bitrix24.rs index c7f0fed..4ea6c2a 100644 --- a/src/services/bitrix24.rs +++ b/src/services/bitrix24.rs @@ -1,6 +1,7 @@ use crate::models::BitrixTask; use reqwest; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct BitrixPortalInfo { @@ -43,19 +44,14 @@ struct BitrixApiGroup { name: String, } -#[derive(Debug, Deserialize, Serialize, Clone)] -struct BitrixCreateTaskResponse { - result: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -struct BitrixCreateTaskResult { - task: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -struct BitrixCreateTaskInfo { - id: Option, +pub fn extract_user_id_from_webhook(webhook: &str) -> Option { + let parts: Vec<&str> = webhook.split('/').collect(); + for (i, part) in parts.iter().enumerate() { + if *part == "rest" && i + 1 < parts.len() { + return parts[i + 1].parse::().ok(); + } + } + None } pub async fn test_connection( @@ -169,30 +165,94 @@ pub async fn fetch_projects( webhook.trim_matches('/') ); - let response = client.get(&full_url).query(&[("MY", "Y")]).send().await?; + let response = client.get(&full_url).query(&[("MY", "Y")]).send().await; - if !response.status().is_success() { - return Err(format!("Bitrix24 API error: {}", response.status()).into()); + if let Ok(resp) = response { + if resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({})); + + if let Some(result) = body.get("result").and_then(|r| r.as_array()) { + let projects: Vec = result + .iter() + .filter_map(|g| { + let id = g.get("ID").and_then(|i| i.as_u64()).map(|i| i as u32)?; + let name = g + .get("NAME") + .and_then(|n| n.as_str()) + .unwrap_or("Без названия") + .to_string(); + Some(BitrixProject { id, name }) + }) + .collect(); + + if !projects.is_empty() { + tracing::info!("Найдено проектов: {}", projects.len()); + return Ok(projects); + } + } + } } - let body: serde_json::Value = response.json().await?; + tracing::info!("socialnetwork_group.get не доступен, пробуем альтернативу..."); - if let Some(error) = body.get("error") { - return Err(format!("Bitrix24 error: {:?}", error).into()); + let full_url = format!( + "{}/{}/tasks.task.list.json", + url.trim_end_matches('/'), + webhook.trim_matches('/') + ); + + let response = client + .post(&full_url) + .json(&serde_json::json!({ + "filter": {}, + "select": ["ID", "TITLE", "GROUP_ID"], + "limit": 100 + })) + .send() + .await; + + if let Ok(resp) = response { + if resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({})); + + if let Some(tasks) = body + .get("result") + .and_then(|r| r.get("tasks")) + .and_then(|t| t.as_array()) + { + let mut projects: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + + for task in tasks { + if let Some(group_id) = task.get("GROUP_ID").and_then(|g| g.as_u64()) { + if group_id > 0 && !seen_ids.contains(&(group_id as u32)) { + seen_ids.insert(group_id as u32); + projects.push(BitrixProject { + id: group_id as u32, + name: format!("Проект #{}", group_id), + }); + } + } + } + + if !projects.is_empty() { + tracing::info!("Найдено проектов из задач: {}", projects.len()); + projects.push(BitrixProject { + id: 0, + name: "Без проекта (личные задачи)".to_string(), + }); + return Ok(projects); + } + } + } } - let groups_response: BitrixGroupsResponse = serde_json::from_value(body)?; + tracing::warn!("Не удалось получить проекты, возвращаем только 'Без проекта'"); - let projects: Vec = groups_response - .result - .into_iter() - .map(|g| BitrixProject { - id: g.id, - name: g.name, - }) - .collect(); - - Ok(projects) + Ok(vec![BitrixProject { + id: 0, + name: "Без проекта (личные задачи)".to_string(), + }]) } pub async fn create_task( @@ -212,38 +272,49 @@ pub async fn create_task( webhook.trim_matches('/') ); - let mut task_data = serde_json::json!({ - "TITLE": title, - "DESCRIPTION": description, - "RESPONSIBLE_ID": responsible_id, - "TASK_CONTROL": false, - "ALLOW_CHANGE_DEADLINE": true, - }); + let mut fields = serde_json::Map::new(); + fields.insert("TITLE".to_string(), serde_json::json!(title)); + fields.insert("DESCRIPTION".to_string(), serde_json::json!(description)); + fields.insert( + "RESPONSIBLE_ID".to_string(), + serde_json::json!(responsible_id), + ); + fields.insert("ALLOW_CHANGE_DEADLINE".to_string(), serde_json::json!(true)); + fields.insert("TAGS".to_string(), serde_json::json!("Redmine")); if project_id > 0 { - task_data["GROUP_ID"] = serde_json::json!(vec![project_id]); + fields.insert("GROUP_ID".to_string(), serde_json::json!(project_id)); } if let Some(dl) = deadline { if !dl.is_empty() { - task_data["DEADLINE"] = serde_json::json!(dl); + let formatted = if dl.contains('T') { + dl.to_string() + } else { + format!("{}T23:59:59+03:00", dl) + }; + fields.insert("DEADLINE".to_string(), serde_json::json!(formatted)); } } - let response = client - .post(&full_url) - .json(&serde_json::json!({ - "FIELDS": task_data, - "REGISTER_NOTIFICATION_ON_ADD": "N" - })) - .send() - .await?; + let request_body = serde_json::json!({ + "fields": fields + }); - if !response.status().is_success() { - return Err(format!("Bitrix24 API error: {}", response.status()).into()); + tracing::info!("Bitrix24 create_task request: {:?}", request_body); + + let response = client.post(&full_url).json(&request_body).send().await?; + + let status = response.status(); + let body_text = response.text().await?; + + tracing::info!("Bitrix24 create_task response: {} - {}", status, body_text); + + if !status.is_success() { + return Err(format!("Bitrix24 API error: {} - {}", status, body_text).into()); } - let body: serde_json::Value = response.json().await?; + let body: serde_json::Value = serde_json::from_str(&body_text)?; if let Some(error) = body.get("error") { return Err(format!("Bitrix24 error: {:?}", error).into()); @@ -251,11 +322,182 @@ pub async fn create_task( let task_id = body .get("result") - .and_then(|r| r.get("TASK")) - .and_then(|t| t.get("ID")) - .and_then(|i| i.as_u64()) - .map(|i| i as u32) - .ok_or("Не удалось получить ID созданной задачи")?; + .and_then(|r| r.get("task")) + .and_then(|t| t.get("id")) + .and_then(|i| { + i.as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| i.as_u64().map(|n| n as u32)) + }) + .ok_or_else(|| format!("Не удалось получить ID задачи из ответа: {}", body_text))?; + + tracing::info!("Создана задача #{}", task_id); Ok(task_id) } + +// ИСПРАВЛЕНО: TASKID передаётся как query параметр +pub async fn add_comment( + url: &str, + webhook: &str, + task_id: u32, + comment_text: &str, + author_id: u32, + _created_on: Option<&str>, +) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // TASKID в URL как query параметр + let full_url = format!( + "{}/{}/task.commentitem.add?taskId={}", + url.trim_end_matches('/'), + webhook.trim_matches('/'), + task_id + ); + + let request_body = serde_json::json!({ + "FIELDS": { + "POST_MESSAGE": comment_text, + "AUTHOR_ID": author_id + } + }); + + tracing::info!("Bitrix24 add_comment request: {:?}", request_body); + + let response = client.post(&full_url).json(&request_body).send().await?; + + let status = response.status(); + let body_text = response.text().await?; + + tracing::info!("Bitrix24 add_comment response: {} - {}", status, body_text); + + if !status.is_success() { + return Err(format!("Bitrix24 comment error: {} - {}", status, body_text).into()); + } + + let body: serde_json::Value = serde_json::from_str(&body_text)?; + + if let Some(error) = body.get("error") { + return Err(format!("Bitrix24 comment error: {:?}", error).into()); + } + + tracing::info!("Комментарий добавлен к задаче #{}", task_id); + + Ok(()) +} + +// Загрузка вложения +pub async fn upload_attachment( + url: &str, + webhook: &str, + file_name: &str, + file_content: &[u8], +) -> Result> { + let client = reqwest::Client::new(); + + let upload_url = format!( + "{}/{}/disk.file.upload.json", + url.trim_end_matches('/'), + webhook.trim_matches('/') + ); + + let file_part = + reqwest::multipart::Part::bytes(file_content.to_vec()).file_name(file_name.to_string()); + + let form = reqwest::multipart::Form::new() + .part("file", file_part) + .text("name", file_name.to_string()); + + let upload_response = client.post(&upload_url).multipart(form).send().await?; + + let upload_body: serde_json::Value = upload_response.json().await?; + + tracing::info!("Bitrix24 disk.file.upload response: {:?}", upload_body); + + if let Some(error) = upload_body.get("error") { + tracing::warn!("Failed to upload file to Bitrix24 disk: {:?}", error); + return Err(format!("Failed to upload file: {:?}", error).into()); + } + + let file_id = upload_body + .get("result") + .and_then(|r| r.get("id")) + .and_then(|i| i.as_u64()) + .ok_or("Не удалось получить ID файла")?; + + let file_doc_id = format!("n{}", file_id); + + tracing::info!("Файл {} загружен с ID: {}", file_name, file_doc_id); + + Ok(file_doc_id) +} + +// ИСПРАВЛЕНО: TASKID передаётся как query параметр +pub async fn add_comment_with_attachments( + url: &str, + webhook: &str, + task_id: u32, + comment_text: &str, + file_doc_ids: Vec, + author_id: u32, + _created_on: Option<&str>, +) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // TASKID в URL как query параметр + let full_url = format!( + "{}/{}/task.commentitem.add?taskId={}", + url.trim_end_matches('/'), + webhook.trim_matches('/'), + task_id + ); + + let mut fields = serde_json::Map::new(); + fields.insert("POST_MESSAGE".to_string(), serde_json::json!(comment_text)); + fields.insert("AUTHOR_ID".to_string(), serde_json::json!(author_id)); + + if !file_doc_ids.is_empty() { + fields.insert( + "UF_FORUM_MESSAGE_DOC".to_string(), + serde_json::json!(file_doc_ids), + ); + } + + let request_body = serde_json::json!({ + "FIELDS": fields + }); + + tracing::info!( + "Bitrix24 add_comment_with_attachments request: {:?}", + request_body + ); + + let response = client.post(&full_url).json(&request_body).send().await?; + + let status = response.status(); + let body_text = response.text().await?; + + tracing::info!( + "Bitrix24 add_comment_with_attachments response: {} - {}", + status, + body_text + ); + + if !status.is_success() { + return Err(format!("Bitrix24 comment error: {} - {}", status, body_text).into()); + } + + let body: serde_json::Value = serde_json::from_str(&body_text)?; + + if let Some(error) = body.get("error") { + return Err(format!("Bitrix24 comment error: {:?}", error).into()); + } + + tracing::info!( + "Комментарий с {} вложениями добавлен к задаче #{}", + file_doc_ids.len(), + task_id + ); + + Ok(()) +} diff --git a/src/services/redmine.rs b/src/services/redmine.rs index ef19785..28d36e1 100644 --- a/src/services/redmine.rs +++ b/src/services/redmine.rs @@ -163,3 +163,57 @@ pub async fn test_connection( let issues = fetch_user_issues(api_key, url, user_id).await?; Ok(issues.len()) } + +pub async fn download_attachment( + content_url: &str, + api_key: &str, +) -> Result, Box> { + let client = reqwest::Client::new(); + + let response = client + .get(content_url) + .header("X-Redmine-API-Key", api_key) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to download attachment: {}", response.status()).into()); + } + + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) +} + +pub async fn get_attachment_info( + api_key: &str, + url: &str, + attachment_id: u32, +) -> Result> { + let client = reqwest::Client::new(); + + let full_url = format!( + "{}/attachments/{}.json", + url.trim_end_matches('/'), + attachment_id + ); + + let response = client + .get(&full_url) + .header("X-Redmine-API-Key", api_key) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to get attachment info: {}", response.status()).into()); + } + + let body: serde_json::Value = response.json().await?; + + let attachment: RedmineAttachment = serde_json::from_value( + body.get("attachment") + .ok_or("No attachment in response")? + .clone(), + )?; + + Ok(attachment) +} diff --git a/templates/import_select.html b/templates/import_select.html index 8a7b530..d79b67f 100644 --- a/templates/import_select.html +++ b/templates/import_select.html @@ -74,6 +74,7 @@ color: #007bff; text-decoration: none; } + .back-link:hover { text-decoration: underline; } .select-all { margin-bottom: 16px; } @@ -81,6 +82,11 @@ color: #666; margin-left: 8px; } + .help-text { + font-size: 12px; + color: #666; + margin-top: 4px; + } @@ -103,6 +109,20 @@ {% endfor %} + +
+ ⚠️ Не видите свой проект? Введите ID вручную:
+ + +
+ 📋 Как узнать ID проекта:
+ 1. Откройте Bitrix24 → Задачи и Проекты
+ 2. Откройте нужный проект
+ 3. В URL будет group/XXX/ или project/XXX/ — это ID +
+
@@ -152,30 +172,53 @@
- +