bitrix tasks are created with comments (imported only 1-by-1)

This commit is contained in:
Ivan I. Ovchinnikov 2026-03-25 22:05:38 +03:00
parent d1570b30c4
commit e238c00a30
7 changed files with 643 additions and 81 deletions

1
Cargo.lock generated
View File

@ -1201,6 +1201,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"once_cell",
"percent-encoding",

View File

@ -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"] }

View File

@ -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<Vec<String>, 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<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or sequence of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Vec<String>, E>
where
E: de::Error,
{
if value.is_empty() {
Ok(Vec::new())
} else {
Ok(vec![value.to_string()])
}
}
fn visit_seq<A>(self, mut seq: A) -> Result<Vec<String>, A::Error>
where
A: de::SeqAccess<'de>,
{
let mut task_ids = Vec::new();
while let Some(id) = seq.next_element::<String>()? {
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<String>,
pub import_comments: Option<String>,
}
pub async fn import_select(session: Session) -> impl IntoResponse {
tracing::info!("📥 Запрос страницы импорта");
let settings = match session
.get::<UserSettings>("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<ImportForm>) -> 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::<UserSettings>("user_settings")
.await
@ -143,10 +200,19 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
{
Some(s) => s,
None => {
eprintln!("❌ ОШИБКА: Настройки не найдены");
return Html("<div class='error'>Сначала настройте подключение</div>".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<ImportForm>) -> 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!(
"<p><strong>Импортировано из Redmine #{}:</strong></p>{}",
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<ImportForm>) -> 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<String> = 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<ImportForm>) -> 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<ImportForm>) -> 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,

View File

@ -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();
}

View File

@ -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<BitrixCreateTaskResult>,
pub fn extract_user_id_from_webhook(webhook: &str) -> Option<u32> {
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::<u32>().ok();
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct BitrixCreateTaskResult {
task: Option<BitrixCreateTaskInfo>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct BitrixCreateTaskInfo {
id: Option<u32>,
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!({}));
let body: serde_json::Value = response.json().await?;
if let Some(error) = body.get("error") {
return Err(format!("Bitrix24 error: {:?}", error).into());
}
let groups_response: BitrixGroupsResponse = serde_json::from_value(body)?;
let projects: Vec<BitrixProject> = groups_response
.result
.into_iter()
.map(|g| BitrixProject {
id: g.id,
name: g.name,
if let Some(result) = body.get("result").and_then(|r| r.as_array()) {
let projects: Vec<BitrixProject> = 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();
Ok(projects)
if !projects.is_empty() {
tracing::info!("Найдено проектов: {}", projects.len());
return Ok(projects);
}
}
}
}
tracing::info!("socialnetwork_group.get не доступен, пробуем альтернативу...");
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<BitrixProject> = Vec::new();
let mut seen_ids: HashSet<u32> = 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);
}
}
}
}
tracing::warn!("Не удалось получить проекты, возвращаем только 'Без проекта'");
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::<u32>().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<dyn std::error::Error + Send + Sync>> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
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<String>,
author_id: u32,
_created_on: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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(())
}

View File

@ -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<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
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<RedmineAttachment, Box<dyn std::error::Error + Send + Sync>> {
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)
}

View File

@ -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;
}
</style>
</head>
<body>
@ -103,6 +109,20 @@
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
<div class="help-text" style="margin-top: 12px; background: #fff3e0; padding: 12px; border-radius: 6px; border-left: 4px solid #f57c00;">
<strong>⚠️ Не видите свой проект?</strong> Введите ID вручную:<br>
<input type="number" id="project_id_manual" name="project_id_manual" placeholder="Например: 105"
style="width: 150px; margin-top: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
onchange="document.getElementById('project_id').value = this.value">
<div style="margin-top: 8px; font-size: 12px; color: #666;">
<strong>📋 Как узнать ID проекта:</strong><br>
1. Откройте Bitrix24 → Задачи и Проекты<br>
2. Откройте нужный проект<br>
3. В URL будет <code>group/XXX/</code> или <code>project/XXX/</code> — это ID
</div>
</div>
</div>
</div>
@ -152,6 +172,20 @@
</div>
<div class="card">
<h3>📋 Опции импорта</h3>
<div style="margin: 16px 0;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="import_comments" id="import_comments" value="on" checked>
<div>
<strong>Импортировать комментарии и вложения</strong>
<div style="font-size: 12px; color: #666; margin-top: 4px;">
Все примечания, история и файлы из Redmine будут добавлены в Bitrix24
</div>
</div>
</label>
</div>
<button type="submit" class="btn btn-success">🚀 Импортировать выбранные задачи</button>
<a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a>
</div>
@ -159,22 +193,31 @@
</div>
<script>
async function submitImport(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = {
project_id: formData.get('project_id'),
task_ids: Array.from(formData.getAll('task_ids'))
};
function toggleAll(checkbox) {
const checkboxes = document.querySelectorAll('input[name="task_ids"]');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
}
const response = await fetch('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
document.addEventListener('DOMContentLoaded', function() {
const manualInput = document.getElementById('project_id_manual');
const selectInput = document.getElementById('project_id');
if (manualInput && selectInput) {
manualInput.addEventListener('input', function() {
if (this.value) {
selectInput.value = this.value;
}
});
window.location.href = '/import/result';
selectInput.addEventListener('change', function() {
if (this.value && this.value !== '0') {
manualInput.value = this.value;
}
});
}
});
</script>
<style>