added detailed view for tasks

This commit is contained in:
Ivan I. Ovchinnikov 2026-03-17 21:47:16 +03:00
parent 32d57b9eaf
commit db6a12bb5f
6 changed files with 722 additions and 100 deletions

View File

@ -1,7 +1,10 @@
use crate::models::Task;
use crate::models::{JournalDetail, Task, TaskAttachment, TaskDetail, TaskJournal};
use crate::services::redmine;
use askama::Template;
use axum::response::{Html, IntoResponse, Json};
use axum::{
extract::Path,
response::{Html, IntoResponse, Json},
};
use serde_json::json;
#[derive(Template)]
@ -11,6 +14,50 @@ struct IndexTemplate {
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 {
tracing::info!("Запрос задач из Redmine");
@ -36,7 +83,7 @@ pub async fn get_redmine_tasks() -> impl IntoResponse {
.map(|issue| Task {
id: issue.id,
source: "redmine".to_string(),
project: None,
project: issue.project.map(|p| p.name),
status_name: issue
.status
.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 {
let template = IndexTemplate {
issues: Vec::new(),
@ -133,3 +143,158 @@ pub async fn get_bitrix_tasks() -> impl IntoResponse {
.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)),
)
}
}
}

View File

@ -26,7 +26,8 @@ async fn main() {
.route("/", get(handlers::index))
.route("/api/redmine", get(handlers::test_redmine))
.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)
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

View File

@ -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,
}
}
}

View File

@ -9,14 +9,25 @@ pub struct RedmineIssue {
pub status: Option<RedmineStatus>,
pub priority: Option<RedminePriority>,
pub assigned_to: Option<RedmineUser>,
pub author: Option<RedmineUser>,
pub project: Option<RedmineProject>,
pub created_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)]
pub struct RedmineStatus {
pub id: u32,
pub name: String,
pub is_closed: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -29,6 +40,57 @@ pub struct RedminePriority {
pub struct RedmineUser {
pub id: u32,
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)]
@ -48,7 +110,8 @@ pub async fn fetch_user_issues(
.query(&[
("assigned_to_id", user_id.to_string()),
("key", api_key.to_string()),
("include", "attachments,journals".to_string()),
("include", "attachments,journals,relations".to_string()),
("limit", "100".to_string()),
])
.send()
.await?;
@ -60,3 +123,34 @@ pub async fn fetch_user_issues(
let body: RedmineIssuesResponse = response.json().await?;
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)
}

View File

@ -5,24 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bitmine — Агрегатор задач</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 20px;
}
.container { max-width: 1400px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.controls {
background: white;
padding: 20px;
@ -39,20 +29,13 @@
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: #0056b3;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-bitrix {
background: #00aeef;
}
.btn-bitrix:hover {
background: #008cc7;
}
.btn:hover { background: #0056b3; }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.btn-bitrix { background: #00aeef; }
.btn-bitrix:hover { background: #008cc7; }
table {
width: 100%;
background: white;
@ -66,14 +49,8 @@
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #333;
}
tr:hover {
background: #f8f9fa;
}
th { background: #f8f9fa; font-weight: 600; color: #333; }
tr:hover { background: #f8f9fa; }
.status {
padding: 4px 12px;
border-radius: 12px;
@ -84,11 +61,6 @@
.status-progress { background: #fff3e0; color: #f57c00; }
.status-resolved { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #ffebee;
color: #c62828;
@ -103,14 +75,8 @@
font-size: 11px;
font-weight: 600;
}
.source-redmine {
background: #5c2d91;
color: white;
}
.source-bitrix {
background: #00aeef;
color: white;
}
.source-redmine { background: #5c2d91; color: white; }
.source-bitrix { background: #00aeef; color: white; }
.note-text {
max-width: 300px;
overflow: hidden;
@ -124,22 +90,14 @@
<h1>🔗 Bitmine — Агрегатор задач</h1>
<div class="controls">
<a href="/api/redmine/tasks" class="btn">
📋 Получить задачи из Redmine
</a>
<button class="btn btn-bitrix" disabled>
📌 Получить задачи из Bitrix24
</button>
<a href="/api/redmine/tasks" class="btn">📋 Получить задачи из Redmine</a>
<button class="btn btn-bitrix" disabled>📌 Получить задачи из Bitrix24</button>
</div>
{% if error.is_some() %}
<div class="error">{{ error.as_ref().unwrap() }}</div>
{% endif %}
<div id="loading" class="loading" style="display: none;">
Загрузка задач...
</div>
{% if !issues.is_empty() %}
<table>
<thead>
@ -154,7 +112,7 @@
</thead>
<tbody>
{% for issue in issues %}
<tr>
<tr style="cursor: pointer;" onclick="window.location.href='/task/{{ issue.id }}'">
<td>{{ issue.id }}</td>
<td>
<span class="source-badge source-{{ issue.source }}">

342
templates/task_detail.html Normal file
View 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>