added detailed view for tasks
This commit is contained in:
parent
32d57b9eaf
commit
db6a12bb5f
@ -1,7 +1,10 @@
|
|||||||
use crate::models::Task;
|
use crate::models::{JournalDetail, Task, TaskAttachment, TaskDetail, TaskJournal};
|
||||||
use crate::services::redmine;
|
use crate::services::redmine;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::response::{Html, IntoResponse, Json};
|
use axum::{
|
||||||
|
extract::Path,
|
||||||
|
response::{Html, IntoResponse, Json},
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@ -11,6 +14,50 @@ struct IndexTemplate {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "task_detail.html")]
|
||||||
|
struct TaskDetailTemplate {
|
||||||
|
task: TaskDetail,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index() -> impl IntoResponse {
|
||||||
|
let template = IndexTemplate {
|
||||||
|
issues: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
Html(
|
||||||
|
template
|
||||||
|
.render()
|
||||||
|
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test_redmine() -> impl IntoResponse {
|
||||||
|
let url = std::env::var("REDMINE_URL")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
let api_key = std::env::var("REDMINE_API_KEY").unwrap_or_default();
|
||||||
|
let user_id: u32 = std::env::var("REDMINE_USER_ID")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
match redmine::fetch_user_issues(&api_key, &url, user_id).await {
|
||||||
|
Ok(issues) => Json(json!({
|
||||||
|
"status": "success",
|
||||||
|
"count": issues.len(),
|
||||||
|
"issues": issues
|
||||||
|
})),
|
||||||
|
Err(e) => Json(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": e.to_string()
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_redmine_tasks() -> impl IntoResponse {
|
pub async fn get_redmine_tasks() -> impl IntoResponse {
|
||||||
tracing::info!("Запрос задач из Redmine");
|
tracing::info!("Запрос задач из Redmine");
|
||||||
|
|
||||||
@ -36,7 +83,7 @@ pub async fn get_redmine_tasks() -> impl IntoResponse {
|
|||||||
.map(|issue| Task {
|
.map(|issue| Task {
|
||||||
id: issue.id,
|
id: issue.id,
|
||||||
source: "redmine".to_string(),
|
source: "redmine".to_string(),
|
||||||
project: None,
|
project: issue.project.map(|p| p.name),
|
||||||
status_name: issue
|
status_name: issue
|
||||||
.status
|
.status
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -85,43 +132,6 @@ pub async fn get_redmine_tasks() -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn index() -> impl IntoResponse {
|
|
||||||
let template = IndexTemplate {
|
|
||||||
issues: Vec::new(),
|
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
Html(
|
|
||||||
template
|
|
||||||
.render()
|
|
||||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn test_redmine() -> impl IntoResponse {
|
|
||||||
let url = std::env::var("REDMINE_URL")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.trim_matches('"')
|
|
||||||
.to_string();
|
|
||||||
let api_key = std::env::var("REDMINE_API_KEY").unwrap_or_default();
|
|
||||||
let user_id: u32 = std::env::var("REDMINE_USER_ID")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
match redmine::fetch_user_issues(&api_key, &url, user_id).await {
|
|
||||||
Ok(issues) => Json(json!({
|
|
||||||
"status": "success",
|
|
||||||
"count": issues.len(),
|
|
||||||
"issues": issues
|
|
||||||
})),
|
|
||||||
Err(e) => Json(json!({
|
|
||||||
"status": "error",
|
|
||||||
"message": e.to_string()
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_bitrix_tasks() -> impl IntoResponse {
|
pub async fn get_bitrix_tasks() -> impl IntoResponse {
|
||||||
let template = IndexTemplate {
|
let template = IndexTemplate {
|
||||||
issues: Vec::new(),
|
issues: Vec::new(),
|
||||||
@ -133,3 +143,158 @@ pub async fn get_bitrix_tasks() -> impl IntoResponse {
|
|||||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_task_detail(Path(issue_id): Path<u32>) -> impl IntoResponse {
|
||||||
|
tracing::info!("Запрос детали задачи #{}", issue_id);
|
||||||
|
|
||||||
|
let url = std::env::var("REDMINE_URL")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
let api_key = std::env::var("REDMINE_API_KEY").unwrap_or_default();
|
||||||
|
|
||||||
|
match redmine::fetch_issue_full(&api_key, &url, issue_id).await {
|
||||||
|
Ok(issue) => {
|
||||||
|
tracing::info!("Получена задача #{}", issue_id);
|
||||||
|
|
||||||
|
let journals: Vec<TaskJournal> = issue
|
||||||
|
.journals
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|j| TaskJournal {
|
||||||
|
id: j.id,
|
||||||
|
user_name: j
|
||||||
|
.user
|
||||||
|
.map(|u| u.name)
|
||||||
|
.unwrap_or_else(|| "Система".to_string()),
|
||||||
|
notes: j.notes.unwrap_or_default(),
|
||||||
|
created_on: j.created_on.unwrap_or_default(),
|
||||||
|
details: j
|
||||||
|
.details
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| JournalDetail {
|
||||||
|
property: d.property.unwrap_or_default(),
|
||||||
|
name: d.name.unwrap_or_default(),
|
||||||
|
old_value: d.old_value.unwrap_or_default(),
|
||||||
|
new_value: d.new_value.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let attachments: Vec<TaskAttachment> = issue
|
||||||
|
.attachments
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| {
|
||||||
|
let content_type = a.content_type.unwrap_or_default();
|
||||||
|
let is_image = TaskAttachment::is_image_type(Some(&content_type));
|
||||||
|
|
||||||
|
TaskAttachment {
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
filesize: a.filesize.unwrap_or(0),
|
||||||
|
content_type: content_type.clone(),
|
||||||
|
content_url: a.content_url.unwrap_or_default(),
|
||||||
|
description: a.description.unwrap_or_default(),
|
||||||
|
author_name: a.author.map(|u| u.name).unwrap_or_else(|| "—".to_string()),
|
||||||
|
created_on: a.created_on.unwrap_or_default(),
|
||||||
|
is_image,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let task_detail = TaskDetail {
|
||||||
|
id: issue.id,
|
||||||
|
source: "redmine".to_string(),
|
||||||
|
subject: issue.subject,
|
||||||
|
description: issue.description.unwrap_or_default(),
|
||||||
|
status_name: issue
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.name.clone())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string()),
|
||||||
|
status_class: Task::status_class_from_name(
|
||||||
|
&issue
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.name.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
priority_name: issue
|
||||||
|
.priority
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "—".to_string()),
|
||||||
|
project_name: issue
|
||||||
|
.project
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_else(|| "—".to_string()),
|
||||||
|
author_name: issue
|
||||||
|
.author
|
||||||
|
.map(|a| a.name)
|
||||||
|
.unwrap_or_else(|| "—".to_string()),
|
||||||
|
assigned_to_name: issue
|
||||||
|
.assigned_to
|
||||||
|
.map(|a| a.name)
|
||||||
|
.unwrap_or_else(|| "—".to_string()),
|
||||||
|
created_on: issue.created_on.unwrap_or_default(),
|
||||||
|
updated_on: issue.updated_on.unwrap_or_default(),
|
||||||
|
start_date: issue.start_date.unwrap_or_default(),
|
||||||
|
due_date: issue.due_date.unwrap_or_default(),
|
||||||
|
done_ratio: issue.done_ratio.unwrap_or(0),
|
||||||
|
estimated_hours: issue.estimated_hours.unwrap_or(0.0),
|
||||||
|
spent_hours: issue.spent_hours.unwrap_or(0.0),
|
||||||
|
journals,
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
let template = TaskDetailTemplate {
|
||||||
|
task: task_detail,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = template.render();
|
||||||
|
match rendered {
|
||||||
|
Ok(html) => Html(html),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Ошибка рендеринга шаблона: {}", e);
|
||||||
|
Html(format!("<div class='error'>Ошибка шаблона: {}</div>", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Ошибка получения задачи #{}: {}", issue_id, e);
|
||||||
|
let template = TaskDetailTemplate {
|
||||||
|
task: TaskDetail {
|
||||||
|
id: issue_id,
|
||||||
|
source: "redmine".to_string(),
|
||||||
|
subject: String::new(),
|
||||||
|
description: String::new(),
|
||||||
|
status_name: String::new(),
|
||||||
|
status_class: "closed".to_string(),
|
||||||
|
priority_name: "—".to_string(),
|
||||||
|
project_name: "—".to_string(),
|
||||||
|
author_name: "—".to_string(),
|
||||||
|
assigned_to_name: "—".to_string(),
|
||||||
|
created_on: String::new(),
|
||||||
|
updated_on: String::new(),
|
||||||
|
start_date: String::new(),
|
||||||
|
due_date: String::new(),
|
||||||
|
done_ratio: 0,
|
||||||
|
estimated_hours: 0.0,
|
||||||
|
spent_hours: 0.0,
|
||||||
|
journals: Vec::new(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
};
|
||||||
|
Html(
|
||||||
|
template
|
||||||
|
.render()
|
||||||
|
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -26,7 +26,8 @@ async fn main() {
|
|||||||
.route("/", get(handlers::index))
|
.route("/", get(handlers::index))
|
||||||
.route("/api/redmine", get(handlers::test_redmine))
|
.route("/api/redmine", get(handlers::test_redmine))
|
||||||
.route("/api/redmine/tasks", get(handlers::get_redmine_tasks))
|
.route("/api/redmine/tasks", get(handlers::get_redmine_tasks))
|
||||||
.route("/api/bitrix/tasks", get(handlers::get_bitrix_tasks));
|
.route("/api/bitrix/tasks", get(handlers::get_bitrix_tasks))
|
||||||
|
.route("/task/:id", get(handlers::get_task_detail));
|
||||||
|
|
||||||
// Запуск сервера (axum 0.7 API)
|
// Запуск сервера (axum 0.7 API)
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||||
|
|||||||
@ -27,3 +27,65 @@ impl Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TaskDetail {
|
||||||
|
pub id: u32,
|
||||||
|
pub source: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status_name: String,
|
||||||
|
pub status_class: String,
|
||||||
|
pub priority_name: String,
|
||||||
|
pub project_name: String,
|
||||||
|
pub author_name: String,
|
||||||
|
pub assigned_to_name: String,
|
||||||
|
pub created_on: String,
|
||||||
|
pub updated_on: String,
|
||||||
|
pub start_date: String,
|
||||||
|
pub due_date: String,
|
||||||
|
pub done_ratio: u32,
|
||||||
|
pub estimated_hours: f32,
|
||||||
|
pub spent_hours: f32,
|
||||||
|
pub journals: Vec<TaskJournal>,
|
||||||
|
pub attachments: Vec<TaskAttachment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TaskJournal {
|
||||||
|
pub id: u32,
|
||||||
|
pub user_name: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub created_on: String,
|
||||||
|
pub details: Vec<JournalDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JournalDetail {
|
||||||
|
pub property: String,
|
||||||
|
pub name: String,
|
||||||
|
pub old_value: String,
|
||||||
|
pub new_value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TaskAttachment {
|
||||||
|
pub id: u32,
|
||||||
|
pub filename: String,
|
||||||
|
pub filesize: u32,
|
||||||
|
pub content_type: String,
|
||||||
|
pub content_url: String,
|
||||||
|
pub description: String,
|
||||||
|
pub author_name: String,
|
||||||
|
pub created_on: String,
|
||||||
|
pub is_image: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskAttachment {
|
||||||
|
pub fn is_image_type(content_type: Option<&str>) -> bool {
|
||||||
|
match content_type {
|
||||||
|
Some(ct) => ct.starts_with("image/"),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,14 +9,25 @@ pub struct RedmineIssue {
|
|||||||
pub status: Option<RedmineStatus>,
|
pub status: Option<RedmineStatus>,
|
||||||
pub priority: Option<RedminePriority>,
|
pub priority: Option<RedminePriority>,
|
||||||
pub assigned_to: Option<RedmineUser>,
|
pub assigned_to: Option<RedmineUser>,
|
||||||
|
pub author: Option<RedmineUser>,
|
||||||
|
pub project: Option<RedmineProject>,
|
||||||
pub created_on: Option<String>,
|
pub created_on: Option<String>,
|
||||||
pub updated_on: Option<String>,
|
pub updated_on: Option<String>,
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
pub due_date: Option<String>,
|
||||||
|
pub done_ratio: Option<u32>,
|
||||||
|
pub estimated_hours: Option<f32>,
|
||||||
|
pub spent_hours: Option<f32>,
|
||||||
|
pub journals: Option<Vec<RedmineJournal>>,
|
||||||
|
pub attachments: Option<Vec<RedmineAttachment>>,
|
||||||
|
pub custom_fields: Option<Vec<RedmineCustomField>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct RedmineStatus {
|
pub struct RedmineStatus {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub is_closed: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
@ -29,6 +40,57 @@ pub struct RedminePriority {
|
|||||||
pub struct RedmineUser {
|
pub struct RedmineUser {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub mail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineProject {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub identifier: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineJournal {
|
||||||
|
pub id: u32,
|
||||||
|
pub user: Option<RedmineUser>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub created_on: Option<String>,
|
||||||
|
pub details: Option<Vec<RedmineJournalDetail>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineJournalDetail {
|
||||||
|
pub property: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub old_value: Option<String>,
|
||||||
|
pub new_value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineAttachment {
|
||||||
|
pub id: u32,
|
||||||
|
pub filename: String,
|
||||||
|
pub filesize: Option<u32>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub content_url: Option<String>,
|
||||||
|
pub digest: Option<String>,
|
||||||
|
pub downloads: Option<u32>,
|
||||||
|
pub author: Option<RedmineUser>,
|
||||||
|
pub created_on: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineCustomField {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub value: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
struct RedmineIssueResponse {
|
||||||
|
issue: RedmineIssue,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
@ -48,7 +110,8 @@ pub async fn fetch_user_issues(
|
|||||||
.query(&[
|
.query(&[
|
||||||
("assigned_to_id", user_id.to_string()),
|
("assigned_to_id", user_id.to_string()),
|
||||||
("key", api_key.to_string()),
|
("key", api_key.to_string()),
|
||||||
("include", "attachments,journals".to_string()),
|
("include", "attachments,journals,relations".to_string()),
|
||||||
|
("limit", "100".to_string()),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@ -60,3 +123,34 @@ pub async fn fetch_user_issues(
|
|||||||
let body: RedmineIssuesResponse = response.json().await?;
|
let body: RedmineIssuesResponse = response.json().await?;
|
||||||
Ok(body.issues)
|
Ok(body.issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_issue_full(
|
||||||
|
api_key: &str,
|
||||||
|
url: &str,
|
||||||
|
issue_id: u32,
|
||||||
|
) -> Result<RedmineIssue, Box<dyn std::error::Error>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!(
|
||||||
|
"{}/issues/{}.json",
|
||||||
|
url.trim_end_matches('/'),
|
||||||
|
issue_id
|
||||||
|
))
|
||||||
|
.query(&[
|
||||||
|
("key", api_key.to_string()),
|
||||||
|
(
|
||||||
|
"include",
|
||||||
|
"attachments,journals,relations,watchers".to_string(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Redmine API error: {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: RedmineIssueResponse = response.json().await?;
|
||||||
|
Ok(body.issue)
|
||||||
|
}
|
||||||
|
|||||||
@ -5,24 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Bitmine — Агрегатор задач</title>
|
<title>Bitmine — Агрегатор задач</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
.container {
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
max-width: 1400px;
|
h1 { color: #333; margin-bottom: 20px; }
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.controls {
|
.controls {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@ -39,20 +29,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn:hover { background: #0056b3; }
|
||||||
background: #0056b3;
|
.btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||||
}
|
.btn-bitrix { background: #00aeef; }
|
||||||
.btn:disabled {
|
.btn-bitrix:hover { background: #008cc7; }
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.btn-bitrix {
|
|
||||||
background: #00aeef;
|
|
||||||
}
|
|
||||||
.btn-bitrix:hover {
|
|
||||||
background: #008cc7;
|
|
||||||
}
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: white;
|
background: white;
|
||||||
@ -66,14 +49,8 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
th {
|
th { background: #f8f9fa; font-weight: 600; color: #333; }
|
||||||
background: #f8f9fa;
|
tr:hover { background: #f8f9fa; }
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
tr:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
.status {
|
.status {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -84,11 +61,6 @@
|
|||||||
.status-progress { background: #fff3e0; color: #f57c00; }
|
.status-progress { background: #fff3e0; color: #f57c00; }
|
||||||
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||||
.status-closed { background: #f5f5f5; color: #616161; }
|
.status-closed { background: #f5f5f5; color: #616161; }
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.error {
|
.error {
|
||||||
background: #ffebee;
|
background: #ffebee;
|
||||||
color: #c62828;
|
color: #c62828;
|
||||||
@ -103,14 +75,8 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.source-redmine {
|
.source-redmine { background: #5c2d91; color: white; }
|
||||||
background: #5c2d91;
|
.source-bitrix { background: #00aeef; color: white; }
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.source-bitrix {
|
|
||||||
background: #00aeef;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.note-text {
|
.note-text {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -124,22 +90,14 @@
|
|||||||
<h1>🔗 Bitmine — Агрегатор задач</h1>
|
<h1>🔗 Bitmine — Агрегатор задач</h1>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<a href="/api/redmine/tasks" class="btn">
|
<a href="/api/redmine/tasks" class="btn">📋 Получить задачи из Redmine</a>
|
||||||
📋 Получить задачи из Redmine
|
<button class="btn btn-bitrix" disabled>📌 Получить задачи из Bitrix24</button>
|
||||||
</a>
|
|
||||||
<button class="btn btn-bitrix" disabled>
|
|
||||||
📌 Получить задачи из Bitrix24
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error.is_some() %}
|
{% if error.is_some() %}
|
||||||
<div class="error">{{ error.as_ref().unwrap() }}</div>
|
<div class="error">{{ error.as_ref().unwrap() }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div id="loading" class="loading" style="display: none;">
|
|
||||||
Загрузка задач...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if !issues.is_empty() %}
|
{% if !issues.is_empty() %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -154,7 +112,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for issue in issues %}
|
{% for issue in issues %}
|
||||||
<tr>
|
<tr style="cursor: pointer;" onclick="window.location.href='/task/{{ issue.id }}'">
|
||||||
<td>{{ issue.id }}</td>
|
<td>{{ issue.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="source-badge source-{{ issue.source }}">
|
<span class="source-badge source-{{ issue.source }}">
|
||||||
|
|||||||
342
templates/task_detail.html
Normal file
342
templates/task_detail.html
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Задача #{{ task.id }} — {{ task.subject }}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(135deg, #5c2d91 0%, #7b42a8 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.card-header h1 { font-size: 24px; margin-bottom: 8px; }
|
||||||
|
.task-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.meta-label { opacity: 0.8; margin-right: 6px; }
|
||||||
|
.card-body { padding: 24px; }
|
||||||
|
.section { margin-bottom: 24px; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-new { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.status-progress { background: #fff3e0; color: #f57c00; }
|
||||||
|
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||||
|
.status-closed { background: #f5f5f5; color: #616161; }
|
||||||
|
.journal-entry {
|
||||||
|
border-left: 3px solid #ddd;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.journal-entry.system {
|
||||||
|
border-left-color: #999;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
.journal-entry:last-child { margin-bottom: 0; }
|
||||||
|
.journal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.journal-user { font-weight: 600; color: #333; }
|
||||||
|
.journal-notes {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.journal-details {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.journal-details table { width: 100%; border-collapse: collapse; }
|
||||||
|
.journal-details td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.journal-details .old-value {
|
||||||
|
color: #c62828;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.journal-details .new-value {
|
||||||
|
color: #388e3c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.attachments-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.attachment-card {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.attachment-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.attachment-preview {
|
||||||
|
height: 150px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.attachment-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.attachment-preview .file-icon { font-size: 48px; color: #999; }
|
||||||
|
.attachment-info { padding: 12px; }
|
||||||
|
.attachment-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.attachment-meta { font-size: 11px; color: #666; }
|
||||||
|
.attachment-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.attachment-link:hover { text-decoration: underline; }
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #4caf50;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.info-label { font-size: 12px; color: #666; margin-bottom: 4px; }
|
||||||
|
.info-value { font-weight: 500; color: #333; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="/api/redmine/tasks" class="back-link">← Назад к списку задач</a>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h1>#{{ task.id }} — {{ task.subject }}</h1>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="status-badge status-{{ task.status_class }}">{{ task.status_name }}</span>
|
||||||
|
<span class="meta-item"><span class="meta-label">Проект:</span>{{ task.project_name }}</span>
|
||||||
|
<span class="meta-item"><span class="meta-label">Приоритет:</span>{{ task.priority_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Автор</div>
|
||||||
|
<div class="info-value">{{ task.author_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Исполнитель</div>
|
||||||
|
<div class="info-value">{{ task.assigned_to_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Создана</div>
|
||||||
|
<div class="info-value">{{ task.created_on }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Обновлена</div>
|
||||||
|
<div class="info-value">{{ task.updated_on }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Начало</div>
|
||||||
|
<div class="info-value">{{ task.start_date }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Срок</div>
|
||||||
|
<div class="info-value">{{ task.due_date }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Готовность</div>
|
||||||
|
<div class="info-value">{{ task.done_ratio }}%</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {{ task.done_ratio }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Оценка (часы)</div>
|
||||||
|
<div class="info-value">{{ task.estimated_hours }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Затрачено (часы)</div>
|
||||||
|
<div class="info-value">{{ task.spent_hours }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if !task.description.is_empty() %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Описание</div>
|
||||||
|
<div class="description">{{ task.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if !task.attachments.is_empty() %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Вложения ({{ task.attachments.len() }})</div>
|
||||||
|
<div class="attachments-grid">
|
||||||
|
{% for attachment in task.attachments %}
|
||||||
|
<div class="attachment-card">
|
||||||
|
<a href="{{ attachment.content_url }}" target="_blank">
|
||||||
|
<div class="attachment-preview">
|
||||||
|
{% if attachment.is_image %}
|
||||||
|
<img src="{{ attachment.content_url }}" alt="{{ attachment.filename }}">
|
||||||
|
{% else %}
|
||||||
|
<span class="file-icon">📄</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="attachment-info">
|
||||||
|
<div class="attachment-name">{{ attachment.filename }}</div>
|
||||||
|
<div class="attachment-meta">
|
||||||
|
{% if attachment.filesize > 0 %}{{ attachment.filesize / 1024 }} КБ{% endif %}
|
||||||
|
{% if !attachment.author_name.is_empty() %} • {{ attachment.author_name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if !attachment.description.is_empty() %}
|
||||||
|
<div class="attachment-meta" style="margin-top: 4px;">
|
||||||
|
{{ attachment.description }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ attachment.content_url }}" class="attachment-link" target="_blank">Скачать →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if !task.journals.is_empty() %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">История изменений ({{ task.journals.len() }})</div>
|
||||||
|
{% for journal in task.journals %}
|
||||||
|
<div class="journal-entry {% if journal.notes.is_empty() && !journal.details.is_empty() %}system{% endif %}">
|
||||||
|
<div class="journal-header">
|
||||||
|
<span class="journal-user">{{ journal.user_name }}</span>
|
||||||
|
<span>{{ journal.created_on }}</span>
|
||||||
|
</div>
|
||||||
|
{% if !journal.notes.is_empty() %}
|
||||||
|
<div class="journal-notes">{{ journal.notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if !journal.details.is_empty() %}
|
||||||
|
<div class="journal-details">
|
||||||
|
<table>
|
||||||
|
{% for detail in journal.details %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if !detail.name.is_empty() %}{{ detail.name }}{% else %}{{ detail.property }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if !detail.old_value.is_empty() %}
|
||||||
|
<span class="old-value">{{ detail.old_value }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if !detail.old_value.is_empty() && !detail.new_value.is_empty() %} → {% endif %}
|
||||||
|
{% if !detail.new_value.is_empty() %}
|
||||||
|
<span class="new-value">{{ detail.new_value }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error.is_some() %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="error" style="background: #ffebee; color: #c62828; padding: 16px; border-radius: 8px;">
|
||||||
|
{{ error.as_ref().unwrap() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user